@wirecat/cli-core 0.537.0 → 0.538.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,39 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.538.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`events prepare [event]`, in the shared calendar commands: what the store holds for one meeting.** The event as
15
+ `events show` gives it, and for each attendee other than the owner their linked identities, latest one-to-one
16
+ messages and mail, their messages in groups, past events and recorded meetings with them, read in every account that
17
+ saw them. Without an event, the next meeting with other attendees. `meta.accounts` names what was read, and
18
+ `meta.complete: false` names a chat the store does not hold whole (`chat.not-read`) or meetings not yet indexed
19
+ (`meetings.index-pending`); `untrusted` marks all third-party text. It summarises nothing. `recordContext` is
20
+ `personContext` for a person already found, read in the accounts the caller names.
21
+ - **A task error names the tasks it is about**: `task.has-subtasks` gives `details.subtasks` (their keys) and
22
+ `task.is-subtask` gives `details.parent`, in the JSON a CLI prints. The keys were raised but not kept.
23
+ - **`parent:` in the project task query**: `parent:<key>` lists a task's subtasks, also by a key it had before it
24
+ moved to another project; `parent:none` and `parent:any`.
25
+
26
+ ### Fixed
27
+
28
+ - **Mail a server search stores first gets the owner's rule tags.** `searchMailServer` saved new mail without the
29
+ `tag:` labels and `mailRuleId` its rules give, and sync never reads mail whose place it already knows, so that mail
30
+ stayed untagged until `rules apply`. It now tags it as `syncMail` does, and writes reused or refused labels back into
31
+ the rules the same way.
32
+ - **A findings batch stays approvable after a sibling batch of the same project is decided.** Accepting checks only
33
+ the evidence and assignee the accepted findings cite. Before, every task a
34
+ decision created moved the project's row, and every other waiting batch of that project was refused as
35
+ `evidence-findings.stale`. A renamed project no longer refuses a batch either; the project must still exist.
36
+ Meeting findings are unchanged.
37
+
38
+ ### Changed — may break callers
39
+
40
+ - **Moving a subtask to another project is refused** with `task.is-subtask` (details `parent`, its parent's key),
41
+ unless the same edit detaches it with `parent: null`. Before, the move dropped its parent without saying so.
42
+
10
43
  ## 0.537.0 — 01.11.2026
11
44
 
12
45
  ### Added
@@ -249,6 +249,149 @@ export declare const calendarAttendee: v.StrictObjectSchema<{
249
249
  readonly events: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
250
250
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
251
251
  }, undefined>;
252
+ export declare const calendarEventPreparation: v.StrictObjectSchema<{
253
+ readonly event: v.StrictObjectSchema<{
254
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
255
+ readonly event_series_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
256
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
257
+ readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
258
+ readonly location: v.NullableSchema<v.StringSchema<undefined>, undefined>;
259
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
260
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
261
+ readonly timezone: v.NullableSchema<v.StringSchema<undefined>, undefined>;
262
+ readonly origin: v.PicklistSchema<["auto", "owner", "calendar"], undefined>;
263
+ readonly status: v.NullableSchema<v.StringSchema<undefined>, undefined>;
264
+ readonly all_day: v.BooleanSchema<undefined>;
265
+ readonly source_deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
266
+ readonly deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
267
+ readonly created_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
268
+ readonly updated_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
269
+ readonly change: v.OptionalSchema<v.PicklistSchema<["new", "moved", "cancelled"], undefined>, undefined>;
270
+ readonly changed_at: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>, undefined>;
271
+ readonly was: v.OptionalSchema<v.StrictObjectSchema<{
272
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
273
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
274
+ }, undefined>, undefined>;
275
+ readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
276
+ readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
277
+ readonly ref: v.StringSchema<undefined>;
278
+ readonly entries: v.ArraySchema<v.StrictObjectSchema<{
279
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
280
+ readonly calendar_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
281
+ readonly external_id: v.StringSchema<undefined>;
282
+ readonly kind: v.StringSchema<undefined>;
283
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
284
+ readonly status: v.StringSchema<undefined>;
285
+ readonly event_type: v.NullableSchema<v.StringSchema<undefined>, undefined>;
286
+ readonly own_response: v.NullableSchema<v.StringSchema<undefined>, undefined>;
287
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
288
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
289
+ readonly join_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
290
+ readonly url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
291
+ readonly deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
292
+ }, undefined>, undefined>;
293
+ readonly attendees: v.ArraySchema<v.StrictObjectSchema<{
294
+ email: v.StringSchema<undefined>;
295
+ name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
296
+ response: v.NullableSchema<v.StringSchema<undefined>, undefined>;
297
+ organizer: v.BooleanSchema<undefined>;
298
+ optional: v.BooleanSchema<undefined>;
299
+ resource: v.BooleanSchema<undefined>;
300
+ self: v.BooleanSchema<undefined>;
301
+ /** `person:<id>` when the shared store knows the address. */
302
+ person: v.NullableSchema<v.StringSchema<undefined>, undefined>;
303
+ }, undefined>, undefined>;
304
+ readonly attachments: v.ArraySchema<v.StrictObjectSchema<{
305
+ readonly file_id: v.StringSchema<undefined>;
306
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
307
+ readonly document: v.NullableSchema<v.StringSchema<undefined>, undefined>;
308
+ }, undefined>, undefined>;
309
+ readonly history: v.ArraySchema<v.StrictObjectSchema<{
310
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
311
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
312
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
313
+ readonly location: v.NullableSchema<v.StringSchema<undefined>, undefined>;
314
+ readonly status: v.NullableSchema<v.StringSchema<undefined>, undefined>;
315
+ readonly changed_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
316
+ }, undefined>, undefined>;
317
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
318
+ }, undefined>;
319
+ readonly people: v.ArraySchema<v.StrictObjectSchema<{
320
+ readonly email: v.StringSchema<undefined>;
321
+ readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
322
+ readonly response: v.NullableSchema<v.StringSchema<undefined>, undefined>;
323
+ readonly organizer: v.BooleanSchema<undefined>;
324
+ readonly optional: v.BooleanSchema<undefined>;
325
+ readonly person: v.NullableSchema<v.StringSchema<undefined>, undefined>;
326
+ readonly read: v.BooleanSchema<undefined>;
327
+ readonly identities: v.ArraySchema<v.StringSchema<undefined>, undefined>;
328
+ readonly recent: v.StrictObjectSchema<{
329
+ readonly direct: v.ArraySchema<v.StrictObjectSchema<{
330
+ readonly locator: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
331
+ readonly provider: v.StringSchema<undefined>;
332
+ readonly account: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
333
+ readonly chat_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
334
+ readonly chat_title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
335
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
336
+ readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
337
+ readonly sender_id: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
338
+ readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
339
+ readonly outgoing: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
340
+ readonly text: v.StringSchema<undefined>;
341
+ }, undefined>, undefined>;
342
+ readonly groups: v.ArraySchema<v.StrictObjectSchema<{
343
+ readonly locator: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
344
+ readonly provider: v.StringSchema<undefined>;
345
+ readonly account: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
346
+ readonly chat_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
347
+ readonly chat_title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
348
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
349
+ readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
350
+ readonly sender_id: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
351
+ readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
352
+ readonly outgoing: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
353
+ readonly text: v.StringSchema<undefined>;
354
+ }, undefined>, undefined>;
355
+ }, undefined>;
356
+ readonly past_events: v.ArraySchema<v.StrictObjectSchema<{
357
+ readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
358
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
359
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
360
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
361
+ readonly all_day: v.BooleanSchema<undefined>;
362
+ readonly location: v.NullableSchema<v.StringSchema<undefined>, undefined>;
363
+ readonly status: v.NullableSchema<v.StringSchema<undefined>, undefined>;
364
+ }, undefined>, undefined>;
365
+ readonly meetings: v.ArraySchema<v.StrictObjectSchema<{
366
+ readonly reference: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
367
+ readonly cues: v.ArraySchema<v.StrictObjectSchema<{
368
+ readonly reference: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
369
+ readonly fingerprint: v.StringSchema<undefined>;
370
+ readonly transcript_id: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
371
+ readonly content_hash: v.NullableSchema<v.StringSchema<undefined>, undefined>;
372
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
373
+ readonly start_ms: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
374
+ readonly end_ms: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
375
+ readonly speaker_participant_id: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
376
+ readonly speaker_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
377
+ readonly text: v.StringSchema<undefined>;
378
+ readonly revision: v.PicklistSchema<["current", "superseded"], undefined>;
379
+ }, undefined>, undefined>;
380
+ readonly cue_coverage: v.StrictObjectSchema<{
381
+ readonly included: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
382
+ readonly omitted: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
383
+ readonly has_more: v.BooleanSchema<undefined>;
384
+ }, undefined>;
385
+ }, undefined>, undefined>;
386
+ readonly has_more: v.BooleanSchema<undefined>;
387
+ }, undefined>, undefined>;
388
+ readonly limits: v.StrictObjectSchema<{
389
+ readonly people: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
390
+ readonly messages: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
391
+ readonly bytes: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
392
+ }, undefined>;
393
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
394
+ }, undefined>;
252
395
  export declare const calendarLinkedPerson: v.StrictObjectSchema<{
253
396
  readonly person: v.StringSchema<undefined>;
254
397
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -2,7 +2,8 @@ import { annotate } from "@wirecat/cli-base/commands";
2
2
  import { queryFieldsOf } from "@wirecat/cli-base/query";
3
3
  import * as v from "valibot";
4
4
  import { answerEntity } from "../../cli/answer-entity.js";
5
- import { count, id, text, time, untrusted } from "../../entities/fields.js";
5
+ import { contextEvent, contextMessage, meetingCue } from "../../entities/contact-context.js";
6
+ import { count, id, reference, text, time, untrusted } from "../../entities/fields.js";
6
7
  import { event } from "../../meetings/index.js";
7
8
  const nullableText = v.nullable(text);
8
9
  const nullableTime = v.nullable(time);
@@ -180,6 +181,31 @@ export const calendarAttendee = v.strictObject({
180
181
  events: v.optional(count),
181
182
  untrusted,
182
183
  });
184
+ /** An attendee of the prepared event other than the owner, with what the store holds of them; `read` false: nothing. */
185
+ const preparedPerson = v.strictObject({
186
+ email: text,
187
+ name: nullableText,
188
+ response: nullableText,
189
+ organizer: v.boolean(),
190
+ optional: v.boolean(),
191
+ person: nullableText,
192
+ read: v.boolean(),
193
+ identities: v.array(text),
194
+ recent: v.strictObject({ direct: v.array(contextMessage), groups: v.array(contextMessage) }),
195
+ past_events: v.array(contextEvent),
196
+ meetings: v.array(v.strictObject({
197
+ reference,
198
+ cues: v.array(meetingCue),
199
+ cue_coverage: v.strictObject({ included: count, omitted: v.nullable(count), has_more: v.boolean() }),
200
+ })),
201
+ has_more: v.boolean(),
202
+ });
203
+ export const calendarEventPreparation = v.strictObject({
204
+ event: calendarEvent,
205
+ people: v.array(preparedPerson),
206
+ limits: v.strictObject({ people: count, messages: count, bytes: count }),
207
+ untrusted,
208
+ });
183
209
  export const calendarLinkedPerson = v.strictObject({ person: text, name: nullableText, identities: v.array(text) });
184
210
  export const calendarLinkReview = v.strictObject({
185
211
  id,
@@ -198,6 +224,7 @@ const ANSWERS = {
198
224
  "store status": { name: "calendar_sync_progress", schema: calendarSyncProgress },
199
225
  "events list": { name: "event" },
200
226
  "events show": { name: "calendar_event", schema: calendarEvent },
227
+ "events prepare": { name: "calendar_event_preparation", schema: calendarEventPreparation },
201
228
  "events create": { name: "calendar_event_write", schema: calendarEventWrite },
202
229
  "events update": { name: "calendar_event_write", schema: calendarEventWrite },
203
230
  "events delete": { name: "calendar_event_write", schema: calendarEventWrite },
@@ -8,8 +8,10 @@ import { queryTerms, withQuery } from "../../cli/list-query.js";
8
8
  import { terminalAsker } from "../../cli/messenger/ask.js";
9
9
  import { lineOf } from "../../cli/steps.js";
10
10
  import { searchEvents } from "../../services/events-search.js";
11
+ import { dateWindow } from "../../services/moment.js";
11
12
  import { linkPeople } from "../../services/people.js";
12
13
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
14
+ import { eventPreparation, nextMeeting, PREPARE_MESSAGES } from "../prepare.js";
13
15
  import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
14
16
  import { REPEAT_HELP } from "../recurrence.js";
15
17
  import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
@@ -40,6 +42,12 @@ const integer = (text) => {
40
42
  throw new InvalidArgumentError("expected a positive integer");
41
43
  return Number(text);
42
44
  };
45
+ const upTo = (most) => (text) => {
46
+ const value = integer(text);
47
+ if (value > most)
48
+ throw new InvalidArgumentError(`expected 1 to ${most}`);
49
+ return value;
50
+ };
43
51
  const minutes = (text) => {
44
52
  const value = integer(text);
45
53
  if (value < 5 || value > 1440)
@@ -232,6 +240,33 @@ const addCalendarCommandsDefinition = (program, deps) => {
232
240
  const shown = await eventAnswer(await eventOf(deps.store, deps.accountId, eventIdOf(reference)), personOfEmail);
233
241
  print({ ...shown, untrusted: CALENDAR_UNTRUSTED_FIELDS }, command);
234
242
  });
243
+ example(annotate(events.command("prepare"), { effects: LOCAL_READ, partial: true })
244
+ .description("before a meeting: the event, its attendees with their answer, and each one's latest messages, mail and " +
245
+ "meetings the store holds; the next meeting when no event is named")
246
+ .summary("what the store holds for one meeting and the people in it")
247
+ .argument("[event]", "the event's id, or event:<id>; the next meeting with other attendees when absent"), "events prepare --json", "events prepare event:42 --limit 3", "events prepare event:42 --since-time 2026-10-01")
248
+ .option("--limit <n>", `messages per person in each list, 1–50; ${PREPARE_MESSAGES} if not given`, upTo(50))
249
+ .option("--since-time <time>", "no message or meeting older than this ISO 8601 time, or 30d ago")
250
+ .action(async (reference, opts, command) => {
251
+ const window = dateWindow({
252
+ ...(opts.sinceTime === undefined ? {} : { sinceTime: opts.sinceTime }),
253
+ zone: zone(),
254
+ now: now(),
255
+ });
256
+ const details = reference === undefined
257
+ ? await nextMeeting(deps.store, deps.accountId, now())
258
+ : await eventOf(deps.store, deps.accountId, eventIdOf(reference));
259
+ const { meta, ...prepared } = await eventPreparation(details, {
260
+ store: deps.store,
261
+ messages: messages("events prepare"),
262
+ account: { id: deps.accountId, key: deps.account },
263
+ personOf: personOfEmail ?? (async () => null),
264
+ now: now(),
265
+ ...(opts.limit === undefined ? {} : { limit: opts.limit }),
266
+ ...(window.since === undefined ? {} : { since: window.since }),
267
+ });
268
+ print({ ...prepared, meta: { ...meta, ...window.meta } }, command);
269
+ });
235
270
  const searches = annotate(program.command("search").description("find stored things by text: events"), {
236
271
  effects: GROUP,
237
272
  });
@@ -0,0 +1,134 @@
1
+ import type { EventDetails, EventStore } from "../meetings/index.js";
2
+ import { type ContextEvent, type ContextMessage } from "../services/person-context.js";
3
+ import { type PersonMeetingContextItem } from "../services/person-meeting-context.js";
4
+ import type { MessageStore } from "../store/store.js";
5
+ import { type PersonOfEmail, type ReadAccount } from "./reads.js";
6
+ export declare const PREPARE_MESSAGES = 5;
7
+ /** Attendees past this many are listed without their messages: a meeting of fifty would not fit any budget. */
8
+ export declare const PREPARE_PEOPLE = 10;
9
+ export declare const PREPARE_BYTES: number;
10
+ /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
11
+ export declare const nextMeeting: (store: EventStore, accountId: number, now: number) => Promise<EventDetails>;
12
+ export interface PreparationOptions {
13
+ store: EventStore;
14
+ messages: MessageStore;
15
+ account: ReadAccount;
16
+ personOf: PersonOfEmail;
17
+ now: number;
18
+ /** Messages per person, each list. */
19
+ limit?: number;
20
+ /** ms: no message or meeting older. */
21
+ since?: number;
22
+ }
23
+ /**
24
+ * The stored evidence for one meeting: the event, and for each attendee other than the owner, their linked identities,
25
+ * their latest messages and mail, the events and recorded meetings they shared with the owner. Reads the store only,
26
+ * every account that saw each person (WC-ACCT-9), and writes nothing; the brief is the agent's.
27
+ */
28
+ export declare const eventPreparation: (details: EventDetails, options: PreparationOptions) => Promise<{
29
+ event: {
30
+ id: string;
31
+ event_series_id: string | null;
32
+ title: string | null;
33
+ description: string | null;
34
+ location: string | null;
35
+ starts_at: string | null;
36
+ ends_at: string | null;
37
+ timezone: string | null;
38
+ origin: "auto" | "calendar" | "owner";
39
+ status: string | null;
40
+ all_day: boolean;
41
+ source_deleted_at: string | null;
42
+ deleted_at: string | null;
43
+ created_at: string;
44
+ updated_at: string;
45
+ untrusted?: string[] | undefined;
46
+ change?: "cancelled" | "moved" | "new" | undefined;
47
+ changed_at?: string | null | undefined;
48
+ was?: {
49
+ starts_at: string | null;
50
+ ends_at: string | null;
51
+ } | undefined;
52
+ overlapping?: boolean | undefined;
53
+ overlaps?: string[] | undefined;
54
+ ref: string;
55
+ entries: {
56
+ id: string;
57
+ calendar_id: string;
58
+ external_id: string;
59
+ kind: import("../meetings/event-store.js").EntryKind;
60
+ title: string | null;
61
+ status: import("../meetings/event-store.js").EntryStatus;
62
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
63
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
64
+ starts_at: string | null;
65
+ ends_at: string | null;
66
+ join_url: string | null;
67
+ url: string | null;
68
+ deleted_at: string | null;
69
+ }[];
70
+ attendees: {
71
+ email: string;
72
+ name: string | null;
73
+ response: import("../meetings/event-store.js").ParticipantResponse | null;
74
+ organizer: boolean;
75
+ optional: boolean;
76
+ resource: boolean;
77
+ self: boolean;
78
+ person: string | null;
79
+ }[];
80
+ attachments: {
81
+ file_id: string;
82
+ title: string | null;
83
+ document: string | null;
84
+ }[];
85
+ history: {
86
+ title: string | null;
87
+ starts_at: string | null;
88
+ ends_at: string | null;
89
+ location: string | null;
90
+ status: string | null;
91
+ changed_at: string;
92
+ }[];
93
+ };
94
+ people: {
95
+ email: string;
96
+ name: string | null;
97
+ response: string | null;
98
+ organizer: boolean;
99
+ optional: boolean;
100
+ person: string | null;
101
+ read: boolean;
102
+ identities: string[];
103
+ recent: {
104
+ direct: ContextMessage[];
105
+ groups: ContextMessage[];
106
+ };
107
+ pastEvents: ContextEvent[];
108
+ meetings: PersonMeetingContextItem[];
109
+ hasMore: boolean;
110
+ }[];
111
+ limits: {
112
+ people: number;
113
+ messages: number;
114
+ bytes: number;
115
+ };
116
+ untrusted: string[];
117
+ meta: Pick<{
118
+ run_id: string;
119
+ duration_ms: number;
120
+ accounts?: string[] | undefined;
121
+ complete: boolean;
122
+ issues?: {
123
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
124
+ reason?: string | undefined;
125
+ source: string;
126
+ }[] | undefined;
127
+ truncated?: boolean | undefined;
128
+ timezone?: string | undefined;
129
+ window?: {
130
+ since?: string | undefined;
131
+ until?: string | undefined;
132
+ } | undefined;
133
+ }, "accounts" | "complete" | "issues" | "truncated">;
134
+ }>;
@@ -0,0 +1,179 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parseMeetingReference } from "../meetings/index.js";
3
+ import { readsOf, recordContext } from "../services/person-context.js";
4
+ import { personMeetingContext } from "../services/person-meeting-context.js";
5
+ import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, eventAnswer, eventOf, } from "./reads.js";
6
+ export const PREPARE_MESSAGES = 5;
7
+ /** Attendees past this many are listed without their messages: a meeting of fifty would not fit any budget. */
8
+ export const PREPARE_PEOPLE = 10;
9
+ export const PREPARE_BYTES = 128 * 1024;
10
+ const PERSON_BYTES = 24 * 1024;
11
+ const MEETINGS = 2;
12
+ const CUES = 5;
13
+ /** How many upcoming events the next-meeting rule looks at before it gives up. */
14
+ const NEXT_SCAN = 200;
15
+ const NEXT_PAGE = 50;
16
+ const nameOf = ({ provider, account }) => `${provider}:${account}`;
17
+ const size = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
18
+ /**
19
+ * What makes an event the next meeting: timed, not cancelled, not declined by the owner, an ordinary event (no focus
20
+ * time, out-of-office or working location), and someone other than the owner and the rooms invited.
21
+ */
22
+ const isMeeting = (details) => {
23
+ if (details.event.allDay || details.event.status === "cancelled")
24
+ return false;
25
+ const live = details.entries.filter((entry) => entry.deletedAt === null && entry.status !== "cancelled");
26
+ if (!live.length || live.every((entry) => entry.ownResponse === "declined"))
27
+ return false;
28
+ if (live.every((entry) => entry.eventType !== null && entry.eventType !== "default"))
29
+ return false;
30
+ return details.participants.some((one) => !one.self && !one.resource);
31
+ };
32
+ /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
33
+ export const nextMeeting = async (store, accountId, now) => {
34
+ for (let offset = 0; offset < NEXT_SCAN; offset += NEXT_PAGE) {
35
+ const page = await store.events({ accountId, startsFrom: now, limit: NEXT_PAGE, offset });
36
+ for (const one of page) {
37
+ if (one.allDay || one.status === "cancelled")
38
+ continue;
39
+ const details = await eventOf(store, accountId, one.id).catch(() => undefined);
40
+ if (details && isMeeting(details))
41
+ return details;
42
+ }
43
+ if (page.length < NEXT_PAGE)
44
+ break;
45
+ }
46
+ throw new CliError("not_found", "no upcoming meeting with other attendees in the store; run sync, or name an event", {
47
+ reason: "calendar.no-upcoming",
48
+ });
49
+ };
50
+ const UNTRUSTED = [
51
+ ...CALENDAR_UNTRUSTED_FIELDS.filter((field) => !["candidates.title", "entry.title", "ended.title"].includes(field)).map((field) => `event.${field}`),
52
+ "people.name",
53
+ ...["direct", "groups"].flatMap((list) => ["chat_title", "sender_name", "text"].map((field) => `people.recent.${list}.${field}`)),
54
+ "people.past_events.title",
55
+ "people.past_events.location",
56
+ "people.meetings.cues.speaker_name",
57
+ "people.meetings.cues.text",
58
+ ];
59
+ /**
60
+ * The stored evidence for one meeting: the event, and for each attendee other than the owner, their linked identities,
61
+ * their latest messages and mail, the events and recorded meetings they shared with the owner. Reads the store only,
62
+ * every account that saw each person (WC-ACCT-9), and writes nothing; the brief is the agent's.
63
+ */
64
+ export const eventPreparation = async (details, options) => {
65
+ const { messages: store, account, personOf, now, since } = options;
66
+ const limit = options.limit ?? PREPARE_MESSAGES;
67
+ const shown = await eventAnswer(details, personOf);
68
+ const others = shown.attendees
69
+ .filter((one) => !one.self && !one.resource)
70
+ .sort((a, b) => Number(b.organizer) - Number(a.organizer));
71
+ const read = new Map([[nameOf(account.key), account.key]]);
72
+ const issues = [];
73
+ const stored = await store.accounts.storedAccounts();
74
+ let left = PREPARE_BYTES - size(shown);
75
+ let truncated = others.length > PREPARE_PEOPLE;
76
+ let pending = 0;
77
+ const people = [];
78
+ for (const [index, attendee] of others.entries()) {
79
+ const base = {
80
+ email: attendee.email,
81
+ name: attendee.name,
82
+ response: attendee.response,
83
+ organizer: attendee.organizer,
84
+ optional: attendee.optional,
85
+ person: attendee.person,
86
+ };
87
+ const record = attendee.person ? await store.people.personOf({ provider: "email", id: attendee.email }) : undefined;
88
+ const nothing = {
89
+ ...base,
90
+ read: false,
91
+ identities: record ? record.identities.map(({ provider, id }) => `${provider}:${id}`) : [],
92
+ recent: { direct: [], groups: [] },
93
+ pastEvents: [],
94
+ meetings: [],
95
+ hasMore: false,
96
+ };
97
+ if (!record || index >= PREPARE_PEOPLE || left < 2048) {
98
+ if (record && index < PREPARE_PEOPLE)
99
+ truncated = true;
100
+ people.push(nothing);
101
+ continue;
102
+ }
103
+ const reads = readsOf(record, account.key);
104
+ for (const { key } of reads)
105
+ read.set(nameOf(key), key);
106
+ const context = await recordContext(store, record, reads, {
107
+ messages: limit,
108
+ bytes: Math.min(left, PERSON_BYTES),
109
+ now,
110
+ ...(since === undefined ? {} : { since }),
111
+ });
112
+ for (const chat of context.notRead)
113
+ issues.push({
114
+ code: "not_found",
115
+ reason: "chat.not-read",
116
+ source: `${chat.provider}:${chat.account}/${chat.chatId}`,
117
+ });
118
+ const meetings = await meetingsWith(store, record.uid, stored, since);
119
+ pending = Math.max(pending, meetings.pending);
120
+ for (const id of meetings.accounts) {
121
+ const one = stored.find((held) => held.id === id);
122
+ if (one)
123
+ read.set(nameOf(one), { provider: one.provider, account: one.account });
124
+ }
125
+ const entry = {
126
+ ...nothing,
127
+ read: true,
128
+ recent: context.recent,
129
+ pastEvents: context.events.recent.filter((one) => one.ref !== shown.ref).slice(0, limit),
130
+ meetings: meetings.items,
131
+ hasMore: context.hasMore || meetings.hasMore,
132
+ };
133
+ left -= size(entry);
134
+ people.push(entry);
135
+ }
136
+ if (pending > 0)
137
+ issues.push({ code: "not_found", reason: "meetings.index-pending", source: "meetings" });
138
+ const calendar = await accountsMeta(options.store, [account], [], now);
139
+ const all = [...(calendar.issues ?? []), ...issues];
140
+ const meta = {
141
+ accounts: [...read.keys()],
142
+ complete: all.length === 0,
143
+ ...(all.length ? { issues: all } : {}),
144
+ ...(truncated || left < 0 ? { truncated: true } : {}),
145
+ };
146
+ return {
147
+ event: shown,
148
+ people,
149
+ limits: { people: PREPARE_PEOPLE, messages: limit, bytes: PREPARE_BYTES },
150
+ untrusted: UNTRUSTED,
151
+ meta,
152
+ };
153
+ };
154
+ /** Their latest recorded meetings in every stored account that saw them; none is not an error. */
155
+ const meetingsWith = async (store, uid, stored, since) => {
156
+ const none = { items: [], hasMore: false, accounts: [], pending: 0 };
157
+ if (!stored.length)
158
+ return none;
159
+ try {
160
+ const found = await personMeetingContext(store, `person:${uid}`, {
161
+ accountIds: stored.slice(0, 1000).map(({ id }) => id),
162
+ meetings: MEETINGS,
163
+ cues: CUES,
164
+ bytes: PERSON_BYTES,
165
+ ...(since === undefined ? {} : { since }),
166
+ });
167
+ return {
168
+ items: found.items,
169
+ hasMore: found.coverage.truncatedBy !== null || found.coverage.scanLimitReached,
170
+ accounts: [...new Set(found.items.map((item) => parseMeetingReference(item.reference).accountId))],
171
+ pending: found.coverage.pending,
172
+ };
173
+ }
174
+ catch (error) {
175
+ if (error instanceof CliError && error.code === "not_found")
176
+ return none;
177
+ throw error;
178
+ }
179
+ };
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  /**
3
3
  * Parses what a shared calendar command handed its host against the command's entity, as a host prints it: keys in
4
- * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`.
4
+ * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`. A `meta`
5
+ * goes into the host's envelope, never into the item.
5
6
  */
6
7
  export declare const checkCalendarAnswer: (root: Command, argv: readonly string[], value: unknown) => void;
@@ -13,14 +13,15 @@ const commandAt = (root, argv) => {
13
13
  };
14
14
  /**
15
15
  * Parses what a shared calendar command handed its host against the command's entity, as a host prints it: keys in
16
- * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`.
16
+ * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`. A `meta`
17
+ * goes into the host's envelope, never into the item.
17
18
  */
18
19
  export const checkCalendarAnswer = (root, argv, value) => {
19
20
  const command = commandAt(root, argv);
20
21
  const schema = answerEntitySchema(command);
21
22
  if (!schema)
22
23
  throw new Error(`${argv.join(" ")} declares no answer entity`);
23
- const { next: _next, ...body } = snakeKeys(value);
24
+ const { next: _next, meta: _meta, ...body } = snakeKeys(value);
24
25
  const marker = body.untrusted
25
26
  ?.filter((field) => field.startsWith("hits."))
26
27
  .map((field) => field.slice("hits.".length));
@@ -1,4 +1,26 @@
1
1
  import * as v from "valibot";
2
+ export declare const contextMessage: v.StrictObjectSchema<{
3
+ readonly locator: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4
+ readonly provider: v.StringSchema<undefined>;
5
+ readonly account: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
6
+ readonly chat_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
7
+ readonly chat_title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
8
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
9
+ readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
10
+ readonly sender_id: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
11
+ readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
12
+ readonly outgoing: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
13
+ readonly text: v.StringSchema<undefined>;
14
+ }, undefined>;
15
+ export declare const contextEvent: v.StrictObjectSchema<{
16
+ readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
17
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
18
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
19
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
20
+ readonly all_day: v.BooleanSchema<undefined>;
21
+ readonly location: v.NullableSchema<v.StringSchema<undefined>, undefined>;
22
+ readonly status: v.NullableSchema<v.StringSchema<undefined>, undefined>;
23
+ }, undefined>;
2
24
  export declare const contactMessages: v.StrictObjectSchema<{
3
25
  readonly person: v.StrictObjectSchema<{
4
26
  readonly uid: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -1401,6 +1423,19 @@ export declare const contactContext: v.UnionSchema<[v.StrictObjectSchema<{
1401
1423
  readonly messages: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1402
1424
  }, undefined>;
1403
1425
  }, undefined>], undefined>;
1426
+ export declare const meetingCue: v.StrictObjectSchema<{
1427
+ readonly reference: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
1428
+ readonly fingerprint: v.StringSchema<undefined>;
1429
+ readonly transcript_id: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1430
+ readonly content_hash: v.NullableSchema<v.StringSchema<undefined>, undefined>;
1431
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1432
+ readonly start_ms: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1433
+ readonly end_ms: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1434
+ readonly speaker_participant_id: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
1435
+ readonly speaker_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
1436
+ readonly text: v.StringSchema<undefined>;
1437
+ readonly revision: v.PicklistSchema<["current", "superseded"], undefined>;
1438
+ }, undefined>;
1404
1439
  export declare const contactPreparation: v.StrictObjectSchema<{
1405
1440
  readonly person: v.StrictObjectSchema<{
1406
1441
  readonly uid: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -4,7 +4,7 @@ import { linkedPerson } from "./contact-activity.js";
4
4
  import { count, id, reference, text, time } from "./fields.js";
5
5
  import { message } from "./message.js";
6
6
  const contextPerson = v.strictObject({ ...linkedPerson.entries, provider: text, id });
7
- const contextMessage = v.strictObject({
7
+ export const contextMessage = v.strictObject({
8
8
  locator: reference,
9
9
  provider: text,
10
10
  account: id,
@@ -25,7 +25,7 @@ const sharedChat = v.strictObject({
25
25
  kind: chat.entries.kind,
26
26
  last_message_at: v.nullable(time),
27
27
  });
28
- const contextEvent = v.strictObject({
28
+ export const contextEvent = v.strictObject({
29
29
  ref: reference,
30
30
  title: v.nullable(text),
31
31
  starts_at: v.nullable(time),
@@ -124,7 +124,7 @@ const coverage = v.strictObject({
124
124
  has_more: v.boolean(),
125
125
  cut_by: v.nullable(v.picklist(["limit", "bytes"])),
126
126
  });
127
- const meetingCue = v.strictObject({
127
+ export const meetingCue = v.strictObject({
128
128
  reference,
129
129
  fingerprint: text,
130
130
  transcript_id: count,
@@ -7,7 +7,7 @@ export declare const REASONS: ErrorReason[];
7
7
  * Detail keys an error from this package may carry, until each is registered on its own reason. Not `cause`:
8
8
  * that can hold a provider's or SQLite's raw text.
9
9
  */
10
- export declare const DETAILS: readonly ["contracts", "calendars", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "partial_output", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "retry_after_ms", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
10
+ export declare const DETAILS: readonly ["contracts", "calendars", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "subtasks", "partial_output", "parent", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "retry_after_ms", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
11
11
  /** What a CLI adds to the shared registry: its own reasons, and the detail keys its adapters raise. */
12
12
  export interface AppErrors {
13
13
  reasons?: readonly ErrorReason[];
@@ -65,6 +65,9 @@ const OTHERS = [
65
65
  ["calendar.read-only", "permission_error", "the calendar takes no new events from this account"],
66
66
  ["calendar.unsynced", "not_found", "the calendar account has no synced calendar in the store"],
67
67
  ["calendar.sync-incomplete", "not_found", "a synced calendar's sync never finished or is mid-round"],
68
+ ["calendar.no-upcoming", "not_found", "no upcoming meeting with other attendees is stored for this account"],
69
+ ["chat.not-read", "not_found", "a chat shared with this person is not held whole in the store"],
70
+ ["meetings.index-pending", "not_found", "some stored meetings are not indexed by person yet"],
68
71
  ["profile.locked", "permission_error", "a profile lock pins this process to its own account"],
69
72
  ["busy.offline", "permission_error", "calendars busy asks the provider; --offline was given"],
70
73
  validation("busy.no-calendars", "no calendar syncs and no person was named, so there is nobody to ask"),
@@ -134,6 +137,7 @@ const OTHERS = [
134
137
  validation("task.column", "the task beside which it moves is in another column than the status asked for"),
135
138
  validation("task.dependency-cycle", "the task would depend on itself"),
136
139
  validation("task.has-subtasks", "the task has subtasks; move or detach them before it moves to another project"),
140
+ validation("task.is-subtask", "a subtask moves to another project only when it is detached from its parent in the same write"),
137
141
  validation("task.duplicate-candidates", "open tasks may be the same job: attach to one, or add it as new"),
138
142
  validation("task.certainty-required", "an agent closes a task with its certainty, 0 to 1"),
139
143
  ["task.pair-decided", "permission_error", "the owner already decided the pair; an agent does not change it"],
@@ -210,7 +214,9 @@ export const DETAILS = [
210
214
  "operation_ids",
211
215
  "text_limit",
212
216
  "store_refused_in",
217
+ "subtasks",
213
218
  "partial_output",
219
+ "parent",
214
220
  "path",
215
221
  "permission",
216
222
  "person_ids",
@@ -9,7 +9,8 @@ import { factsOf } from "./facts.js";
9
9
  import { mailHeadersOf } from "./headers.js";
10
10
  import { parseMail } from "./parse.js";
11
11
  import { storedMailRules } from "./rule-stored.js";
12
- import { decide } from "./rules.js";
12
+ import { ruleTagger, settleRuleTags } from "./rule-tags.js";
13
+ import { decide, tagsOf } from "./rules.js";
13
14
  import { mailCriteriaOf } from "./server-query.js";
14
15
  const refusal = {
15
16
  unsupported: "this mailbox's server cannot be searched — use --backend archive",
@@ -74,6 +75,8 @@ export const searchMailServer = async (store, mailbox, access, request) => {
74
75
  };
75
76
  const accountId = await store.accounts.saveAccount(mailbox, { name: mailbox.account });
76
77
  const fresh = [];
78
+ let rules = [];
79
+ const ruled = new Map();
77
80
  let raws;
78
81
  let opening;
79
82
  let openFailure;
@@ -124,7 +127,7 @@ export const searchMailServer = async (store, mailbox, access, request) => {
124
127
  if (!open)
125
128
  return;
126
129
  const unique = [...new Map(listed.map((item) => [item.messageId, item])).values()];
127
- const rules = await storedMailRules(store, { address: mailbox.account });
130
+ rules = await storedMailRules(store, { address: mailbox.account });
128
131
  const facts = factsOf(unique, rules);
129
132
  for (const item of unique) {
130
133
  const held = (await store.mail.email(accountId, item.messageId)) !== null;
@@ -133,8 +136,10 @@ export const searchMailServer = async (store, mailbox, access, request) => {
133
136
  continue;
134
137
  report.returned += 1;
135
138
  sources.set(sourceKey(item.threadId, item.messageId), held ? "both" : "server");
136
- if (!held)
137
- fresh.push(item);
139
+ if (held)
140
+ continue;
141
+ fresh.push(item);
142
+ ruled.set(item, { ruleId: rule?.id ?? null, tags: tagsOf(rules, facts(item)) });
138
143
  }
139
144
  if (fresh.length)
140
145
  raws = await source.fetchRaw(fresh);
@@ -175,6 +180,7 @@ export const searchMailServer = async (store, mailbox, access, request) => {
175
180
  }
176
181
  const now = Date.now();
177
182
  const inputs = [];
183
+ const saved = [];
178
184
  for (const [index, item] of fresh.entries()) {
179
185
  const raw = raws?.[index];
180
186
  try {
@@ -201,10 +207,12 @@ export const searchMailServer = async (store, mailbox, access, request) => {
201
207
  : { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
202
208
  ...(item.seen === undefined ? {} : { read: item.seen }),
203
209
  ...(item.flagged === undefined ? {} : { flagged: item.flagged }),
210
+ mailRuleId: ruled.get(item)?.ruleId ?? null,
204
211
  attachments: files,
205
212
  },
206
213
  ],
207
214
  });
215
+ saved.push(item);
208
216
  }
209
217
  catch {
210
218
  sources.delete(sourceKey(item.threadId, item.messageId));
@@ -213,6 +221,14 @@ export const searchMailServer = async (store, mailbox, access, request) => {
213
221
  }
214
222
  if (inputs.length)
215
223
  await store.mail.saveThreads(inputs);
224
+ // Sync never reads mail whose place it knows again, so a rule's tags go on here or never.
225
+ const tagger = ruleTagger(store, mailbox);
226
+ for (const item of saved) {
227
+ const tags = ruled.get(item)?.tags ?? [];
228
+ if (tags.length)
229
+ await tagger.tag({ type: "message", chatId: item.threadId, messageId: item.messageId }, tags);
230
+ }
231
+ await settleRuleTags(store, accountId, rules, tagger.outcome());
216
232
  report.new = inputs.length;
217
233
  report.returned = sources.size;
218
234
  return { report, sources };
@@ -38,7 +38,6 @@ export declare const meetingTools: ({
38
38
  max_read_bytes?: undefined;
39
39
  cursor?: undefined;
40
40
  } | {
41
- limit?: undefined;
42
41
  event_id?: undefined;
43
42
  series_id?: undefined;
44
43
  page?: undefined;
@@ -54,11 +53,11 @@ export declare const meetingTools: ({
54
53
  format?: undefined;
55
54
  transcript_id?: undefined;
56
55
  query?: undefined;
56
+ limit?: undefined;
57
57
  scan_limit?: undefined;
58
58
  max_read_bytes?: undefined;
59
59
  cursor?: undefined;
60
60
  } | {
61
- limit?: undefined;
62
61
  event_id?: undefined;
63
62
  series_id?: undefined;
64
63
  page?: undefined;
@@ -76,11 +75,11 @@ export declare const meetingTools: ({
76
75
  format?: undefined;
77
76
  transcript_id?: undefined;
78
77
  query?: undefined;
78
+ limit?: undefined;
79
79
  scan_limit?: undefined;
80
80
  max_read_bytes?: undefined;
81
81
  cursor?: undefined;
82
82
  } | {
83
- limit?: undefined;
84
83
  event_id?: undefined;
85
84
  series_id?: undefined;
86
85
  page?: undefined;
@@ -102,6 +101,7 @@ export declare const meetingTools: ({
102
101
  minimum: number;
103
102
  };
104
103
  query?: undefined;
104
+ limit?: undefined;
105
105
  scan_limit?: undefined;
106
106
  max_read_bytes?: undefined;
107
107
  cursor?: undefined;
@@ -188,7 +188,6 @@ export declare const meetingTools: ({
188
188
  max_read_bytes?: undefined;
189
189
  cursor?: undefined;
190
190
  } | {
191
- limit?: undefined;
192
191
  event_id?: undefined;
193
192
  series_id?: undefined;
194
193
  page?: undefined;
@@ -204,11 +203,11 @@ export declare const meetingTools: ({
204
203
  format?: undefined;
205
204
  transcript_id?: undefined;
206
205
  query?: undefined;
206
+ limit?: undefined;
207
207
  scan_limit?: undefined;
208
208
  max_read_bytes?: undefined;
209
209
  cursor?: undefined;
210
210
  } | {
211
- limit?: undefined;
212
211
  event_id?: undefined;
213
212
  series_id?: undefined;
214
213
  page?: undefined;
@@ -226,11 +225,11 @@ export declare const meetingTools: ({
226
225
  format?: undefined;
227
226
  transcript_id?: undefined;
228
227
  query?: undefined;
228
+ limit?: undefined;
229
229
  scan_limit?: undefined;
230
230
  max_read_bytes?: undefined;
231
231
  cursor?: undefined;
232
232
  } | {
233
- limit?: undefined;
234
233
  event_id?: undefined;
235
234
  series_id?: undefined;
236
235
  page?: undefined;
@@ -252,6 +251,7 @@ export declare const meetingTools: ({
252
251
  minimum: number;
253
252
  };
254
253
  query?: undefined;
254
+ limit?: undefined;
255
255
  scan_limit?: undefined;
256
256
  max_read_bytes?: undefined;
257
257
  cursor?: undefined;
@@ -338,7 +338,6 @@ export declare const meetingTools: ({
338
338
  max_read_bytes?: undefined;
339
339
  cursor?: undefined;
340
340
  } | {
341
- limit?: undefined;
342
341
  event_id?: undefined;
343
342
  series_id?: undefined;
344
343
  page?: undefined;
@@ -354,11 +353,11 @@ export declare const meetingTools: ({
354
353
  format?: undefined;
355
354
  transcript_id?: undefined;
356
355
  query?: undefined;
356
+ limit?: undefined;
357
357
  scan_limit?: undefined;
358
358
  max_read_bytes?: undefined;
359
359
  cursor?: undefined;
360
360
  } | {
361
- limit?: undefined;
362
361
  event_id?: undefined;
363
362
  series_id?: undefined;
364
363
  page?: undefined;
@@ -376,11 +375,11 @@ export declare const meetingTools: ({
376
375
  format?: undefined;
377
376
  transcript_id?: undefined;
378
377
  query?: undefined;
378
+ limit?: undefined;
379
379
  scan_limit?: undefined;
380
380
  max_read_bytes?: undefined;
381
381
  cursor?: undefined;
382
382
  } | {
383
- limit?: undefined;
384
383
  event_id?: undefined;
385
384
  series_id?: undefined;
386
385
  page?: undefined;
@@ -402,6 +401,7 @@ export declare const meetingTools: ({
402
401
  minimum: number;
403
402
  };
404
403
  query?: undefined;
404
+ limit?: undefined;
405
405
  scan_limit?: undefined;
406
406
  max_read_bytes?: undefined;
407
407
  cursor?: undefined;
@@ -488,7 +488,6 @@ export declare const meetingTools: ({
488
488
  max_read_bytes?: undefined;
489
489
  cursor?: undefined;
490
490
  } | {
491
- limit?: undefined;
492
491
  event_id?: undefined;
493
492
  series_id?: undefined;
494
493
  page?: undefined;
@@ -504,11 +503,11 @@ export declare const meetingTools: ({
504
503
  format?: undefined;
505
504
  transcript_id?: undefined;
506
505
  query?: undefined;
506
+ limit?: undefined;
507
507
  scan_limit?: undefined;
508
508
  max_read_bytes?: undefined;
509
509
  cursor?: undefined;
510
510
  } | {
511
- limit?: undefined;
512
511
  event_id?: undefined;
513
512
  series_id?: undefined;
514
513
  page?: undefined;
@@ -526,11 +525,11 @@ export declare const meetingTools: ({
526
525
  format?: undefined;
527
526
  transcript_id?: undefined;
528
527
  query?: undefined;
528
+ limit?: undefined;
529
529
  scan_limit?: undefined;
530
530
  max_read_bytes?: undefined;
531
531
  cursor?: undefined;
532
532
  } | {
533
- limit?: undefined;
534
533
  event_id?: undefined;
535
534
  series_id?: undefined;
536
535
  page?: undefined;
@@ -552,6 +551,7 @@ export declare const meetingTools: ({
552
551
  minimum: number;
553
552
  };
554
553
  query?: undefined;
554
+ limit?: undefined;
555
555
  scan_limit?: undefined;
556
556
  max_read_bytes?: undefined;
557
557
  cursor?: undefined;
@@ -638,7 +638,6 @@ export declare const meetingTools: ({
638
638
  max_read_bytes?: undefined;
639
639
  cursor?: undefined;
640
640
  } | {
641
- limit?: undefined;
642
641
  event_id?: undefined;
643
642
  series_id?: undefined;
644
643
  page?: undefined;
@@ -654,11 +653,11 @@ export declare const meetingTools: ({
654
653
  format?: undefined;
655
654
  transcript_id?: undefined;
656
655
  query?: undefined;
656
+ limit?: undefined;
657
657
  scan_limit?: undefined;
658
658
  max_read_bytes?: undefined;
659
659
  cursor?: undefined;
660
660
  } | {
661
- limit?: undefined;
662
661
  event_id?: undefined;
663
662
  series_id?: undefined;
664
663
  page?: undefined;
@@ -676,11 +675,11 @@ export declare const meetingTools: ({
676
675
  format?: undefined;
677
676
  transcript_id?: undefined;
678
677
  query?: undefined;
678
+ limit?: undefined;
679
679
  scan_limit?: undefined;
680
680
  max_read_bytes?: undefined;
681
681
  cursor?: undefined;
682
682
  } | {
683
- limit?: undefined;
684
683
  event_id?: undefined;
685
684
  series_id?: undefined;
686
685
  page?: undefined;
@@ -702,6 +701,7 @@ export declare const meetingTools: ({
702
701
  minimum: number;
703
702
  };
704
703
  query?: undefined;
704
+ limit?: undefined;
705
705
  scan_limit?: undefined;
706
706
  max_read_bytes?: undefined;
707
707
  cursor?: undefined;
@@ -93,7 +93,12 @@ export declare const readsOf: (record: PersonRecord, asked: AccountKey) => {
93
93
  * What the store knows about one person, across every identity linked to them. Reads the store
94
94
  * only: nothing is fetched, nothing marked read. Identity is by id, never by name.
95
95
  */
96
- export declare const personContext: (store: MessageStore, asked: AccountKey, reference: string, { messages, bytes, since, now }?: ContextOptions) => Promise<PersonContext>;
96
+ export declare const personContext: (store: MessageStore, asked: AccountKey, reference: string, options?: ContextOptions) => Promise<PersonContext>;
97
+ /** `personContext` of a person already found, read in the identities and accounts `reads` names. */
98
+ export declare const recordContext: (store: MessageStore, record: PersonRecord, reads: {
99
+ key: AccountKey;
100
+ identity: LinkedIdentity;
101
+ }[], { messages, bytes, since, now }?: ContextOptions) => Promise<Omit<PersonContext, "person">>;
97
102
  /** `max:Ana` names a person in another messenger of this store; a reference with no prefix, this one. */
98
103
  export declare const identityIn: (store: MessageStore, asked: AccountKey, reference: string) => Promise<IdentityRef>;
99
104
  export declare const CHAT_MESSAGES = 20;
@@ -92,11 +92,18 @@ export const readsOf = (record, asked) => record.identities.flatMap((identity) =
92
92
  * What the store knows about one person, across every identity linked to them. Reads the store
93
93
  * only: nothing is fetched, nothing marked read. Identity is by id, never by name.
94
94
  */
95
- export const personContext = async (store, asked, reference, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since, now = Date.now() } = {}) => {
95
+ export const personContext = async (store, asked, reference, options = {}) => {
96
96
  const found = pickPerson(reference, await store.people.people(asked.provider, { account: asked.account }));
97
97
  const record = await store.people.personOf({ provider: asked.provider, id: found.id });
98
98
  if (!record)
99
99
  throw new CliError("not_found", `no person ${found.id} in the store`);
100
+ return {
101
+ person: { ...record, provider: asked.provider, id: found.id },
102
+ ...(await recordContext(store, record, readsOf(record, asked), options)),
103
+ };
104
+ };
105
+ /** `personContext` of a person already found, read in the identities and accounts `reads` names. */
106
+ export const recordContext = async (store, record, reads, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since, now = Date.now() } = {}) => {
100
107
  const recent = (message) => since === undefined || Date.parse(message.timestamp) >= since;
101
108
  const shared = [];
102
109
  const notRead = [];
@@ -106,7 +113,7 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
106
113
  const theirs = [];
107
114
  const mentions = [];
108
115
  let cut = false;
109
- for (const { key, identity } of readsOf(record, asked)) {
116
+ for (const { key, identity } of reads) {
110
117
  const chats = await store.chats.chatsWith(key, identity.id);
111
118
  for (const chat of chats) {
112
119
  shared.push({ ...key, chatId: chat.id, title: chat.title, kind: chat.kind, lastMessageAt: chat.lastMessageAt });
@@ -177,7 +184,6 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
177
184
  cut ||= direct.length > messages || groups.length > messages || mentions.length > messages;
178
185
  const kept = withinBytes(lists, bytes);
179
186
  return {
180
- person: { ...record, provider: asked.provider, id: found.id },
181
187
  shared: shared.sort((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? "")),
182
188
  last: {
183
189
  fromThem: each.find((message) => !message.outgoing) ?? null,
@@ -165,7 +165,9 @@ export const evidenceFindingsStoreOver = (context) => {
165
165
  }
166
166
  const input = proposal.payload;
167
167
  // Rejecting creates nothing, so a batch whose evidence changed can still be closed. Accepting needs only the
168
- // accepted findings' own project, assignee and evidence unchanged; a batch staged before parts were kept needs all.
168
+ // accepted findings' own evidence and assignee unchanged. The project is left out: every task a sibling batch
169
+ // creates moves its row, and the project still has to exist, which citing checks. A batch staged before parts
170
+ // were kept needs all.
169
171
  if (accepted.length) {
170
172
  const parts = input.snapshotParts;
171
173
  if (!parts) {
@@ -175,7 +177,7 @@ export const evidenceFindingsStoreOver = (context) => {
175
177
  else {
176
178
  const chosen = { ...batch.input, findings: batch.input.findings.filter((f) => accepted.includes(f.id)) };
177
179
  const now = core.snapshotParts(chosen, cite());
178
- if (Object.entries(now).some(([key, hash]) => parts[key] !== hash))
180
+ if (Object.entries(now).some(([key, hash]) => !key.startsWith("project:") && parts[key] !== hash))
179
181
  return stale();
180
182
  }
181
183
  }
@@ -27,6 +27,7 @@ export const PROJECT_TASK_FIELDS = [
27
27
  relation: "low, medium, high, critical or none; a range of labels or of the 0–1 number: priority:[high TO critical]",
28
28
  }),
29
29
  field("agent", "kw", "agent:hermes", { relation: "the agent working on it, by name; none or any" }),
30
+ field("parent", "kw", "parent:HRB-1", { relation: "the subtasks of a task, by its key; none or any" }),
30
31
  field("due", "time", "due:[today TO 2026-12-31]", {
31
32
  relation: "the due date; today, tomorrow and yesterday are your calendar days; NOT due:* has none",
32
33
  }),
@@ -134,6 +135,20 @@ const agentWhere = (node) => {
134
135
  ? { sql: "lower(t.agent) GLOB ?", params: [value] }
135
136
  : { sql: "lower(t.agent) = ?", params: [value] };
136
137
  };
138
+ /** A key the task had before it moved to another project still names it. */
139
+ const parentWhere = (node) => {
140
+ const value = node.value.trim();
141
+ if (value.toLowerCase() === "none")
142
+ return { sql: "t.parent_id IS NULL", params: [] };
143
+ if (value.toLowerCase() === "any")
144
+ return { sql: "t.parent_id IS NOT NULL", params: [] };
145
+ const key = value.replace(/^task:/, "");
146
+ return {
147
+ sql: "t.parent_id IN (SELECT id FROM tasks WHERE key = ? OR package_id = ? UNION SELECT task_id FROM task_events " +
148
+ "WHERE kind = 'project' AND json_extract(changes, '$.key[0]') = ?)",
149
+ params: [key.toUpperCase(), key, key.toUpperCase()],
150
+ };
151
+ };
137
152
  const truth = (node, sql) => ({
138
153
  sql: node.value.toLowerCase() === "true" ? sql : `NOT (${sql})`,
139
154
  params: [],
@@ -166,6 +181,7 @@ export const projectTaskColumns = (now, zone) => ({
166
181
  assignee: { where: assigneeWhere },
167
182
  priority: { where: priorityWhere },
168
183
  agent: { where: agentWhere },
184
+ parent: { where: parentWhere },
169
185
  due: { type: "time", sql: "t.due_at" },
170
186
  closed: { type: "time", sql: "t.closed_at" },
171
187
  overdue: { where: (node) => truth(node, dueGroups(now, zone).overdue) },
@@ -31,7 +31,10 @@ export interface TaskFields {
31
31
  reason?: string | null;
32
32
  /** A name the caller checked against its category list; every change is a `category` event. */
33
33
  category?: string;
34
- /** Another owner project, by key or `project:<id>`: the task takes the next key there; its old key still finds it. */
34
+ /**
35
+ * Another owner project, by key or `project:<id>`: the task takes the next key there; its old key still finds it.
36
+ * A subtask moves only with `parent: null` in the same edit.
37
+ */
35
38
  project?: string;
36
39
  /** A project task's kind, bug, feature or chore. */
37
40
  type?: WorkTaskType;
@@ -170,7 +170,7 @@ export const workTaskOperationsOver = (context) => {
170
170
  const details = (id) => detailsOf(rowById(id));
171
171
  const onBoard = (task) => Boolean(database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task));
172
172
  /** The task takes the target's next number and key; the source's counter never goes back, so its old key stays unique. */
173
- const moveToProject = (task, before, target, actor, at) => {
173
+ const moveToProject = (task, before, target, actor, at, detach) => {
174
174
  if (target === Number(before.project_id))
175
175
  return;
176
176
  if (!onBoard(task))
@@ -178,6 +178,10 @@ export const workTaskOperationsOver = (context) => {
178
178
  const targetRow = database.prepare("SELECT key FROM projects WHERE id = ?").get(target);
179
179
  if (String(targetRow.key).length === INBOX_KEY_LENGTH)
180
180
  throw new CliError("validation_error", "a task moves only into one of your projects");
181
+ if (before.parent_id != null && !detach) {
182
+ const parent = taskIdOf(database, Number(before.parent_id));
183
+ throw new CliError("validation_error", `${String(before.key)} is a subtask of ${parent}; detach it in the same write (parent none) to move it alone`, { reason: "task.is-subtask", parent });
184
+ }
181
185
  const children = database
182
186
  .prepare("SELECT key FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id")
183
187
  .all(task)
@@ -280,7 +284,7 @@ export const workTaskOperationsOver = (context) => {
280
284
  bump = true;
281
285
  }
282
286
  if (fields.project !== undefined)
283
- moveToProject(task, before, projectOf(fields.project), actor, at);
287
+ moveToProject(task, before, projectOf(fields.project), actor, at, fields.parent === null);
284
288
  if (set.length === 0) {
285
289
  if (bump)
286
290
  database.prepare("UPDATE tasks SET revision = revision + 1 WHERE id = ?").run(task);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.537.0",
3
+ "version": "0.538.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",