@wirecat/cli-core 0.527.0 → 0.529.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
@@ -5,7 +5,52 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.529.0 — 01.11.2026
9
+
10
+ ### Added
11
+
12
+ - **`people.add({ name })`** adds a person known by name only, to assign a task to before any message names them.
13
+ `tasks.taskPeople({ limit })` lists everyone the store knows, the owner first, then by how many live tasks name
14
+ them; it took only the owner and people with a task role.
15
+ - **`tasks.create` takes `status`**, so a task starts in its column with its due date and agent in one write.
16
+ - **`priorityOf` reads a score with a decimal point**, `0.0` to `1.0`; a bare `0` … `4` keeps the old scale.
17
+ - **`when:` in the project task query**: `overdue`, `today`, `upcoming` (the 7 days after today), `later` or
18
+ `none`, by the owner's calendar day. `boardCounts` counts them in `due`, and counts tasks by `agent`; each project
19
+ count carries the `ref` a task's `project` names. A task carries `projectKey` and `projectName`
20
+ (`project_key`, `project_name` in the `task` and `task_card` entities).
21
+ - **A findings batch can be about a person, an organization, a task or nobody**, and cite a `note:`: a fact finding
22
+ takes an optional `subject` and may name no project; a task finding still names its project. A batch holds at most
23
+ one project. `contextRecordOf` reads notes, so a note's fingerprint can be cited. `evidenceFindings.list({ project })`
24
+ also finds a batch whose findings name that project.
25
+ - **`dateOrNone`** from `./cli`: the `--due` parser of the shared `tasks update`, for a CLI's own `tasks add`.
26
+
27
+ ### Fixed
28
+
29
+ - **`overdue:true` no longer matches a task due later today**: it is open work due before today, in the owner's zone.
30
+ - **A task's history gives due dates and other times as ISO**, as the task does, not epoch milliseconds.
31
+ - **Scoring mail senders no longer hides a broken store.** `scoreSenders` treated any failure of the account lookup
32
+ as "no such account" and returned quietly; now only `not_found` does, and a locked or damaged store fails the
33
+ sync as it does elsewhere.
34
+
35
+ ### Changed — may break callers
36
+
37
+ - **`EvidenceFindingsBatch.project` is `null`** when the findings name no project, and the batch carries `subject`.
38
+ A finding's `project` is optional in `FindingsInput`; meeting findings still require it.
39
+ - **Needs `@wirecat/cli-base` 0.71.x** (peer range `>=0.71.0 <0.72.0`, was 0.68.x). 0.71.0 lets `--to` take an e-mail
40
+ address and `--subject` a subject line in the shared command vocabulary, which mail-cli uses. A CLI on cli-base
41
+ 0.68 or 0.69 moves to 0.71 together with this version.
42
+
43
+ ## 0.528.0 — 01.11.2026
44
+
45
+ ### Added
46
+
47
+ - **Every profile keeps its messenger library's warnings, and `doctor` counts the last day's.** A message the
48
+ library could not decrypt used to leave no trace unless the CLI's own log variable was set. `providerLogPathFor(app,
49
+ profile, env)` names `<state>/provider-log/<profile>.jsonl`; `openProviderLog(path)` makes it private (mode 600)
50
+ and keeps it under 1 MB with one older file, so an engine in another process can append to the path it is given;
51
+ `writeProviderWarning(path, kind, message)` appends from Node. A line is `{"time","level","kind"?,"message"}`, the
52
+ shape zerolog writes; `kind` is the adapter's tag. The adapter masks phone numbers, ids of people and text before
53
+ writing. `doctor` answers `provider_log: { path, last_day: { total, kinds } }` and checks the file is private.
9
54
 
10
55
  ## 0.527.0 — 01.11.2026
11
56
 
@@ -23,6 +23,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, type Replied, type Replier,
23
23
  export { repliesStatePathFor } from "../replies/state.js";
24
24
  export { type CacheEvent, type DiagnosticEvent, type EventSink, providerErrorKey, type RequestEvent, renderEvent, type WarningEvent, } from "../runs/events.js";
25
25
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
26
+ export { openProviderLog, PROVIDER_LOG_BYTES, type ProviderWarnings, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
26
27
  export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
27
28
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunContext, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
28
29
  export { runContextOf, secretInput } from "../runs/run-context.js";
@@ -105,6 +106,7 @@ export { STEMS_EVERY_MS, STEMS_SLICE_MS, type StemFills, stemFills } from "./mes
105
106
  export type { Running, ServerOptions, ServerProcess, ServerSystem } from "./messenger/server-command.js";
106
107
  export { storeSummary } from "./messenger/store-maintenance-command.js";
107
108
  export { type Saving, stored } from "./messenger/stored.js";
109
+ export { dateOrNone } from "./messenger/tasks-command.js";
108
110
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
109
111
  export { window, withPaging } from "./paging.js";
110
112
  export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
package/dist/cli/index.js CHANGED
@@ -19,6 +19,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, replyTo } from "../replies/
19
19
  export { repliesStatePathFor } from "../replies/state.js";
20
20
  export { providerErrorKey, renderEvent, } from "../runs/events.js";
21
21
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
22
+ export { openProviderLog, PROVIDER_LOG_BYTES, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
22
23
  export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
23
24
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
24
25
  export { runContextOf, secretInput } from "../runs/run-context.js";
@@ -94,6 +95,7 @@ export { DAY_MS, FIRST_FETCH_MS, memberFetches } from "./messenger/serve-members
94
95
  export { STEMS_EVERY_MS, STEMS_SLICE_MS, stemFills } from "./messenger/serve-stems.js";
95
96
  export { storeSummary } from "./messenger/store-maintenance-command.js";
96
97
  export { stored } from "./messenger/stored.js";
98
+ export { dateOrNone } from "./messenger/tasks-command.js";
97
99
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
98
100
  export { window, withPaging } from "./paging.js";
99
101
  export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
@@ -5,6 +5,7 @@ import { annotate } from "@wirecat/cli-base/commands";
5
5
  import { Command } from "commander";
6
6
  import { engineStates } from "../../attachments/extract.js";
7
7
  import { providerErrorKey } from "../../runs/events.js";
8
+ import { providerLogPathFor, providerWarnings } from "../../runs/provider-log.js";
8
9
  import { listRuns, runsDirFor, runtime } from "../../runs/run.js";
9
10
  import { FloodMemory, floodPathFor } from "../../sends/flood.js";
10
11
  import { SendJournal, sendsPathFor } from "../../sends/journal.js";
@@ -20,6 +21,7 @@ import { recalledAccount } from "./accounts.js";
20
21
  import { messengerContext } from "./context.js";
21
22
  import { privateFiles, withSqliteSidecars } from "./private-files.js";
22
23
  import { buildReport, reportFileName } from "./report.js";
24
+ import { DAY_MS } from "./serve-members.js";
23
25
  import { storeSummary } from "./store-maintenance-command.js";
24
26
  /**
25
27
  * The state this installation is in, read from disk and **never from the messenger unless
@@ -121,6 +123,7 @@ const rememberedOf = (report) => report.account?.remembered ?? undefined;
121
123
  * What `doctor` answers, as data. A configuration that will not load is a field, not an exception:
122
124
  * then there is no renderer from the settings, and the caller renders with the defaults.
123
125
  */
126
+ const providerLogState = ({ path, total, kinds }) => ({ path, last_day: { total, kinds } });
124
127
  const diagnose = async (command, messenger) => {
125
128
  const { app, provider } = messenger;
126
129
  const env = environmentOf(command).env ?? process.env;
@@ -183,8 +186,9 @@ const diagnose = async (command, messenger) => {
183
186
  const store = storePath(env);
184
187
  const sends = sendsPathFor(app, profile, env);
185
188
  const flood = floodPathFor(app, profile, env);
189
+ const providerLog = providerLogPathFor(app, profile, env);
186
190
  const files = privateFiles({
187
- files: [...own.files, ...withSqliteSidecars(store), sends, flood],
191
+ files: [...own.files, ...withSqliteSidecars(store), sends, flood, providerLog],
188
192
  // A folder the owner chose with MESSAGING_STORE may rightly be shared, like the home folder itself.
189
193
  dirs: [
190
194
  ...own.dirs,
@@ -192,6 +196,7 @@ const diagnose = async (command, messenger) => {
192
196
  dirname(sends),
193
197
  dirname(flood),
194
198
  runsDirFor(app, env),
199
+ dirname(providerLog),
195
200
  ],
196
201
  });
197
202
  return {
@@ -209,6 +214,7 @@ const diagnose = async (command, messenger) => {
209
214
  readers: engineStates(),
210
215
  sends: sendsState(new SendJournal(sendsPathFor(app, profile, env))),
211
216
  flood: floodState(new FloodMemory(flood)),
217
+ provider_log: providerLogState(providerWarnings(providerLog, new Date(Date.now() - DAY_MS))),
212
218
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
213
219
  // The messenger's own block keeps the keys it gives: some of them are file paths.
214
220
  files: filesReport(files),
@@ -1,5 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { type Messenger } from "./context.js";
3
+ /** `none` clears a date; anything else is a time ahead. Not `null`: commander stores a parser's `null` as "". */
4
+ export declare const dateOrNone: (flag: string) => (typed: string) => "none" | Date;
3
5
  /** What waits on the owner — kept in the local store by `src/tasks/`; nothing here reaches the messenger. */
4
6
  export declare const tasksCommand: (messenger: Messenger) => Command;
5
7
  export declare const tasksStatsCommand: (messenger: Messenger) => Command;
@@ -92,7 +92,7 @@ const pairLine = (pair) => [
92
92
  `(${pair.reasons.join(", ")})`,
93
93
  ].join(" ");
94
94
  /** `none` clears a date; anything else is a time ahead. Not `null`: commander stores a parser's `null` as "". */
95
- const dateOrNone = (flag) => (typed) => typed.trim().toLowerCase() === "none" ? "none" : new Date(aheadOf(typed, flag));
95
+ export const dateOrNone = (flag) => (typed) => typed.trim().toLowerCase() === "none" ? "none" : new Date(aheadOf(typed, flag));
96
96
  const durationOf = (flag) => (typed) => {
97
97
  const wanted = typed.trim();
98
98
  const ms = Date.now() - momentOf(wanted, flag);
@@ -4761,6 +4761,8 @@ export declare const ENTITIES: {
4761
4761
  readonly state: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
4762
4762
  readonly origin: v.OptionalSchema<v.PicklistSchema<readonly ["rule", "agent", "owner"], undefined>, undefined>;
4763
4763
  readonly project: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4764
+ readonly project_key: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4765
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4764
4766
  readonly account: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
4765
4767
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4766
4768
  readonly source_message: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
@@ -4829,6 +4831,8 @@ export declare const ENTITIES: {
4829
4831
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4830
4832
  readonly key: v.StringSchema<undefined>;
4831
4833
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4834
+ readonly project_key: v.StringSchema<undefined>;
4835
+ readonly project_name: v.StringSchema<undefined>;
4832
4836
  readonly title: v.StringSchema<undefined>;
4833
4837
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4834
4838
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -4933,6 +4937,8 @@ export declare const ENTITIES: {
4933
4937
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4934
4938
  readonly key: v.StringSchema<undefined>;
4935
4939
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4940
+ readonly project_key: v.StringSchema<undefined>;
4941
+ readonly project_name: v.StringSchema<undefined>;
4936
4942
  readonly title: v.StringSchema<undefined>;
4937
4943
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4938
4944
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -5038,6 +5044,8 @@ export declare const ENTITIES: {
5038
5044
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5039
5045
  readonly key: v.StringSchema<undefined>;
5040
5046
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5047
+ readonly project_key: v.StringSchema<undefined>;
5048
+ readonly project_name: v.StringSchema<undefined>;
5041
5049
  readonly title: v.StringSchema<undefined>;
5042
5050
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5043
5051
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -5171,6 +5179,8 @@ export declare const ENTITIES: {
5171
5179
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5172
5180
  readonly key: v.StringSchema<undefined>;
5173
5181
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5182
+ readonly project_key: v.StringSchema<undefined>;
5183
+ readonly project_name: v.StringSchema<undefined>;
5174
5184
  readonly title: v.StringSchema<undefined>;
5175
5185
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5176
5186
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -5269,6 +5279,8 @@ export declare const ENTITIES: {
5269
5279
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5270
5280
  readonly key: v.StringSchema<undefined>;
5271
5281
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5282
+ readonly project_key: v.StringSchema<undefined>;
5283
+ readonly project_name: v.StringSchema<undefined>;
5272
5284
  readonly title: v.StringSchema<undefined>;
5273
5285
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5274
5286
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -4,6 +4,8 @@ export declare const taskCard: v.StrictObjectSchema<{
4
4
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5
5
  readonly key: v.StringSchema<undefined>;
6
6
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
7
+ readonly project_key: v.StringSchema<undefined>;
8
+ readonly project_name: v.StringSchema<undefined>;
7
9
  readonly title: v.StringSchema<undefined>;
8
10
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
9
11
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -108,6 +110,8 @@ export declare const taskCreated: v.StrictObjectSchema<{
108
110
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
109
111
  readonly key: v.StringSchema<undefined>;
110
112
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
113
+ readonly project_key: v.StringSchema<undefined>;
114
+ readonly project_name: v.StringSchema<undefined>;
111
115
  readonly title: v.StringSchema<undefined>;
112
116
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
113
117
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -213,6 +217,8 @@ export declare const taskLinks: v.UnionSchema<[v.StrictObjectSchema<{
213
217
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
214
218
  readonly key: v.StringSchema<undefined>;
215
219
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
220
+ readonly project_key: v.StringSchema<undefined>;
221
+ readonly project_name: v.StringSchema<undefined>;
216
222
  readonly title: v.StringSchema<undefined>;
217
223
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
218
224
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -346,6 +352,8 @@ export declare const taskMerge: v.StrictObjectSchema<{
346
352
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
347
353
  readonly key: v.StringSchema<undefined>;
348
354
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
355
+ readonly project_key: v.StringSchema<undefined>;
356
+ readonly project_name: v.StringSchema<undefined>;
349
357
  readonly title: v.StringSchema<undefined>;
350
358
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
351
359
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -444,6 +452,8 @@ export declare const taskUnmerge: v.StrictObjectSchema<{
444
452
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
445
453
  readonly key: v.StringSchema<undefined>;
446
454
  readonly project: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
455
+ readonly project_key: v.StringSchema<undefined>;
456
+ readonly project_name: v.StringSchema<undefined>;
447
457
  readonly title: v.StringSchema<undefined>;
448
458
  readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
449
459
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
@@ -25,6 +25,8 @@ export const taskCard = v.strictObject({
25
25
  ref: reference,
26
26
  key: text,
27
27
  project: reference,
28
+ project_key: text,
29
+ project_name: text,
28
30
  title: text,
29
31
  description: v.nullable(text),
30
32
  type: v.picklist(TASK_TYPES),
@@ -19,6 +19,9 @@ export declare const task: v.StrictObjectSchema<{
19
19
  readonly state: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
20
20
  readonly origin: v.OptionalSchema<v.PicklistSchema<readonly ["rule", "agent", "owner"], undefined>, undefined>;
21
21
  readonly project: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
22
+ /** The project's key and name, as `tasks counts` names it. */
23
+ readonly project_key: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
24
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
22
25
  readonly account: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
23
26
  /** The chat it came from, as a `chat:` reference. */
24
27
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
@@ -22,6 +22,9 @@ export const task = v.strictObject({
22
22
  state: v.picklist(TASK_STATES),
23
23
  origin: v.optional(v.picklist(TASK_ORIGINS)),
24
24
  project: v.optional(v.nullable(text)),
25
+ /** The project's key and name, as `tasks counts` names it. */
26
+ project_key: v.optional(v.nullable(text)),
27
+ project_name: v.optional(v.nullable(text)),
25
28
  account: v.optional(text),
26
29
  /** The chat it came from, as a `chat:` reference. */
27
30
  chat: v.optional(v.nullable(reference)),
@@ -1,3 +1,4 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { domainsToAge, groupKey, probeMail } from "./probe.js";
2
3
  import { decide } from "./rules.js";
3
4
  import { FRAUD_SIGNALS, FREE_MAIL, registrableOf, WEIGHTS } from "./score.js";
@@ -22,7 +23,11 @@ export const scoreSenders = async (store, address, ages, now = Date.now(), rules
22
23
  const done = last === undefined ? undefined : JSON.parse(last);
23
24
  if (done !== undefined && done.version === SCORE_VERSION && now - done.at < DAY)
24
25
  return undefined;
25
- const stored = await store.accounts.storedAccount(key).catch(() => null);
26
+ const stored = await store.accounts.storedAccount(key).catch((error) => {
27
+ if (error instanceof CliError && error.code === "not_found")
28
+ return null;
29
+ throw error;
30
+ });
26
31
  if (stored === null)
27
32
  return undefined;
28
33
  const items = (await store.mail.emailFacts({ accountId: stored.id })).map((email) => {
@@ -6,7 +6,10 @@ export interface FindingEvidence {
6
6
  }
7
7
  interface FindingBase {
8
8
  id: string;
9
- project: string;
9
+ /** Every task finding has one; a fact finding may have none when its rules allow it. */
10
+ project?: string;
11
+ /** Who or what a fact is about: `person:`, `organization:`, `task:` or `project:`. */
12
+ subject?: string;
10
13
  evidence: FindingEvidence[];
11
14
  }
12
15
  export interface FactFinding extends FindingBase {
@@ -54,6 +57,8 @@ export interface FindingsRules {
54
57
  /** "cue" */
55
58
  cited: string;
56
59
  reference: (raw: string) => string;
60
+ /** A fact finding may name no project, and any finding may name a subject. */
61
+ anySubject?: boolean;
57
62
  }
58
63
  export declare const validateFindings: (input: FindingsInput, rules: FindingsRules) => FindingsInput;
59
64
  export declare const findingFingerprint: ({ id: _id, ...finding }: Finding) => string;
@@ -16,6 +16,7 @@ const fields = (value, allowed, noun) => {
16
16
  if (Object.keys(value).some((key) => !allowed.includes(key)))
17
17
  invalid(`unknown ${noun} field`);
18
18
  };
19
+ const SUBJECT = /^(person|organization|project):[1-9]\d*$|^task:[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
19
20
  export const validateFindings = (input, rules) => {
20
21
  if (!input || typeof input !== "object" || Buffer.byteLength(JSON.stringify(input), "utf8") > 65536)
21
22
  return invalid(`${rules.plural} take at most 65536 UTF-8 bytes`);
@@ -36,9 +37,13 @@ export const validateFindings = (input, rules) => {
36
37
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(id) || ids.has(id))
37
38
  return invalid("finding IDs must be unique simple keys");
38
39
  ids.add(id);
39
- const project = text(finding.project, 64);
40
- if (!/^project:[1-9]\d*$/.test(project))
40
+ const project = finding.project === undefined && rules.anySubject ? undefined : text(finding.project, 64);
41
+ if (project !== undefined && !/^project:[1-9]\d*$/.test(project))
41
42
  return invalid("a finding names a project reference");
43
+ if (finding.subject !== undefined && (!rules.anySubject || !SUBJECT.test(text(finding.subject, 80))))
44
+ return invalid("a finding's subject is a person:, organization:, task: or project: reference");
45
+ if (finding.kind === "task" && project === undefined)
46
+ return invalid("a task finding names the project the task goes in");
42
47
  if (!Array.isArray(finding.evidence) || finding.evidence.length < 1 || finding.evidence.length > 8)
43
48
  return invalid(`a finding requires 1–8 cited ${rules.cited}s`);
44
49
  const evidence = finding.evidence
@@ -56,9 +61,14 @@ export const validateFindings = (input, rules) => {
56
61
  .sort((a, b) => a.reference.localeCompare(b.reference));
57
62
  if (new Set(evidence.map((item) => item.reference)).size !== evidence.length)
58
63
  return invalid(`duplicate evidence ${rules.cited}`);
59
- const base = { id, project, evidence };
64
+ const base = {
65
+ id,
66
+ ...(project === undefined ? {} : { project }),
67
+ ...(finding.subject === undefined ? {} : { subject: finding.subject }),
68
+ evidence,
69
+ };
60
70
  if (finding.kind === "fact") {
61
- fields(finding, ["id", "project", "evidence", "kind", "type", "statement"], rules.singular);
71
+ fields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
62
72
  if (!FACT_TYPES.includes(finding.type))
63
73
  return invalid("unknown fact finding type");
64
74
  return { ...base, kind: "fact", type: finding.type, statement: text(finding.statement, 2000) };
@@ -0,0 +1,22 @@
1
+ import type { AppIdentity } from "../ports/app.js";
2
+ /**
3
+ * The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
4
+ * trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
5
+ * engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
6
+ * adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
7
+ */
8
+ export declare const providerLogPathFor: (app: AppIdentity, profile: string, env?: NodeJS.ProcessEnv) => string;
9
+ export declare const PROVIDER_LOG_BYTES: number;
10
+ /**
11
+ * The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
12
+ * writer, because an engine in another language appends without knowing it: one older file is kept.
13
+ */
14
+ export declare const openProviderLog: (path: string, maxBytes?: number) => string;
15
+ export declare const writeProviderWarning: (path: string, kind: string, message: string, now?: Date) => void;
16
+ export interface ProviderWarnings {
17
+ path: string;
18
+ total: number;
19
+ kinds: Record<string, number>;
20
+ }
21
+ /** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
22
+ export declare const providerWarnings: (path: string, since: Date) => ProviderWarnings;
@@ -0,0 +1,55 @@
1
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolvePaths } from "@wirecat/cli-base";
4
+ /**
5
+ * The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
6
+ * trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
7
+ * engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
8
+ * adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
9
+ */
10
+ export const providerLogPathFor = (app, profile, env = process.env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "provider-log", `${profile}.jsonl`);
11
+ export const PROVIDER_LOG_BYTES = 1024 * 1024;
12
+ const previousOf = (path) => path.replace(/\.jsonl$/, ".1.jsonl");
13
+ /**
14
+ * The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
15
+ * writer, because an engine in another language appends without knowing it: one older file is kept.
16
+ */
17
+ export const openProviderLog = (path, maxBytes = PROVIDER_LOG_BYTES) => {
18
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
19
+ if (existsSync(path) && statSync(path).size >= maxBytes)
20
+ renameSync(path, previousOf(path));
21
+ closeSync(openSync(path, "a", 0o600));
22
+ chmodSync(path, 0o600);
23
+ return path;
24
+ };
25
+ export const writeProviderWarning = (path, kind, message, now = new Date()) => {
26
+ openProviderLog(path);
27
+ appendFileSync(path, `${JSON.stringify({ time: now.toISOString(), level: "warn", kind, message })}\n`);
28
+ };
29
+ /** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
30
+ export const providerWarnings = (path, since) => {
31
+ const kinds = {};
32
+ let total = 0;
33
+ for (const file of [previousOf(path), path]) {
34
+ if (!existsSync(file))
35
+ continue;
36
+ for (const line of readFileSync(file, "utf8").split("\n")) {
37
+ if (!line)
38
+ continue;
39
+ let entry;
40
+ try {
41
+ entry = JSON.parse(line);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ const at = typeof entry.time === "string" ? Date.parse(entry.time) : Number.NaN;
47
+ if (!(at >= since.getTime()))
48
+ continue;
49
+ const kind = typeof entry.kind === "string" && entry.kind ? entry.kind : "other";
50
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
51
+ total++;
52
+ }
53
+ }
54
+ return { path, total, kinds };
55
+ };
@@ -20,7 +20,10 @@ export interface EvidenceFindingsReceipt extends FindingsReceipt {
20
20
  }
21
21
  export interface EvidenceFindingsBatch {
22
22
  id: string;
23
- project: string;
23
+ /** `null` when its findings name no one project. */
24
+ project: string | null;
25
+ /** Who or what every finding is about, when they agree: a project, person, organization or task; else `null`. */
26
+ subject: string | null;
24
27
  input: FindingsInput;
25
28
  fingerprint: string;
26
29
  status: "proposed" | "reviewed";
@@ -3,13 +3,19 @@ import { formatLocator, parseLocator } from "../domain/locator.js";
3
3
  import { validateFindings } from "../meetings/index.js";
4
4
  export const EVIDENCE_FINDINGS_KIND = "evidence_findings";
5
5
  const reference = (raw) => {
6
- if (/^document:[1-9]\d*$/.test(raw))
6
+ if (/^(document|note):[1-9]\d*$/.test(raw))
7
7
  return raw;
8
8
  try {
9
9
  return formatLocator(parseLocator(raw));
10
10
  }
11
11
  catch {
12
- throw new CliError("validation_error", "finding evidence is a msg: or document: reference");
12
+ throw new CliError("validation_error", "finding evidence is a msg:, document: or note: reference");
13
13
  }
14
14
  };
15
- export const validateEvidenceFindings = (input) => validateFindings(input, { plural: "evidence findings", singular: "evidence finding", cited: "reference", reference });
15
+ export const validateEvidenceFindings = (input) => validateFindings(input, {
16
+ plural: "evidence findings",
17
+ singular: "evidence finding",
18
+ cited: "reference",
19
+ reference,
20
+ anySubject: true,
21
+ });
@@ -59,6 +59,10 @@ export interface PeopleStore extends AccountPeople {
59
59
  unlinkIdentity(identity: IdentityRef, options: LinkOptions): Promise<PersonRecord>;
60
60
  /** The identity, and everyone linked with it, is the owner. */
61
61
  linkToOwner(identity: IdentityRef): Promise<PersonRecord>;
62
+ /** A person known by name only, with no identity yet: someone to assign a task to before any message names them. */
63
+ add(input: {
64
+ name: string;
65
+ }): Promise<PersonRecord>;
62
66
  }
63
67
  export declare const accountPeopleOver: (context: StoreContext) => AccountPeople;
64
68
  export declare const peopleStoreOver: (context: StoreContext, keyed: AccountPeople) => PeopleStore;
@@ -62,6 +62,18 @@ export const peopleStoreOver = (context, keyed) => {
62
62
  personOf: async (identity) => personLinks.personOf(context, identity),
63
63
  personSeenInAccounts: async (uid, accountIds) => personSeenInAccounts(context, uid, accountIds),
64
64
  personByUid: async (uid) => personLinks.personByUid(context, uid),
65
+ add: async ({ name }) => {
66
+ const text = typeof name === "string" ? name.trim() : "";
67
+ if (!text || text.length > 200)
68
+ throw new CliError("validation_error", "a person's name takes 1–200 characters");
69
+ const id = write(database, () => {
70
+ const at = context.now();
71
+ return Number(database
72
+ .prepare("INSERT INTO persons (name, owner, created_at, updated_at) VALUES (?, 0, ?, ?) RETURNING id")
73
+ .get(text, at, at)?.id);
74
+ });
75
+ return personLinks.personByUid(context, String(id));
76
+ },
65
77
  owner: async () => {
66
78
  try {
67
79
  return personLinks.personByUid(context, String(ownerPerson(database).id));
@@ -258,6 +258,20 @@ export const contextRecordOf = (database, ref) => {
258
258
  }
259
259
  : null;
260
260
  }
261
+ const note = /^note:([1-9]\d*)$/.exec(ref);
262
+ if (note) {
263
+ const row = database
264
+ .prepare("SELECT title, body, revision FROM notes WHERE id=? AND deleted_at IS NULL")
265
+ .get(Number(note[1]));
266
+ return row
267
+ ? {
268
+ ref,
269
+ title: row.title == null ? null : String(row.title),
270
+ text: String(row.body),
271
+ revision: Number(row.revision),
272
+ }
273
+ : null;
274
+ }
261
275
  let at;
262
276
  try {
263
277
  at = parseLocator(ref);
@@ -33,13 +33,16 @@ export const evidenceFindingsStoreOver = (context) => {
33
33
  return invalid("stored findings exceed the read budget");
34
34
  return proposals.get(id);
35
35
  };
36
+ /** The one project its findings name, or `null` when none does. */
37
+ const projectOf = (input) => input.findings.find((finding) => finding.project)?.project ?? null;
36
38
  const view = (proposal) => {
37
39
  const input = proposal.payload;
38
- if (!input || !proposal.target || !["proposed", "approved", "rejected"].includes(proposal.status))
40
+ if (!input || !["proposed", "approved", "rejected"].includes(proposal.status))
39
41
  return invalid("invalid stored findings batch");
40
42
  return {
41
43
  id: proposal.id,
42
- project: proposal.target,
44
+ project: projectOf(input),
45
+ subject: proposal.target,
43
46
  input: { schemaVersion: 1, key: input.key, extractor: input.extractor, findings: input.findings },
44
47
  fingerprint: findingHash({ input, author: proposal.proposedBy.id }),
45
48
  status: proposal.status === "proposed" ? "proposed" : "reviewed",
@@ -94,9 +97,12 @@ export const evidenceFindingsStoreOver = (context) => {
94
97
  return {
95
98
  stage: async (raw, rawAgent) => {
96
99
  const input = validateEvidenceFindings(raw);
97
- const projects = new Set(input.findings.map((finding) => finding.project));
98
- if (projects.size !== 1)
99
- return invalid("an evidence findings batch is about one project");
100
+ const projects = new Set(input.findings.flatMap((finding) => finding.project ?? []));
101
+ if (projects.size > 1)
102
+ return invalid("an evidence findings batch is about one project at most");
103
+ const subjects = new Set(input.findings.map((finding) => finding.subject));
104
+ const shared = subjects.size === 1 ? [...subjects][0] : undefined;
105
+ const target = shared ?? [...projects][0];
100
106
  const by = findingsAgent(rawAgent);
101
107
  return write(database, () => {
102
108
  const { actor } = authorActor(context, by);
@@ -118,7 +124,7 @@ export const evidenceFindingsStoreOver = (context) => {
118
124
  return view(proposals.propose({
119
125
  kind: EVIDENCE_FINDINGS_KIND,
120
126
  by,
121
- target: [...projects][0],
127
+ ...(target === undefined ? {} : { target }),
122
128
  payload: { ...input, inputHash, snapshotHash, snapshotParts, agentModel: by.model ?? null },
123
129
  }));
124
130
  });
@@ -134,8 +140,10 @@ export const evidenceFindingsStoreOver = (context) => {
134
140
  if (projectId === undefined)
135
141
  return invalid("a findings list filters by a project reference");
136
142
  const ids = database
137
- .prepare("SELECT id FROM proposed_actions WHERE account_id IS NULL AND kind=? AND (? IS NULL OR (target_type='project' AND target_id=?)) AND (? IS NULL OR status=?) ORDER BY id DESC LIMIT ? OFFSET ?")
138
- .all(EVIDENCE_FINDINGS_KIND, projectId, projectId === null ? null : Number(projectId), status ?? null, status === "reviewed" ? "approved" : (status ?? null), limit + 1, offset);
143
+ .prepare("SELECT id FROM proposed_actions WHERE account_id IS NULL AND kind=? AND (? IS NULL OR (target_type='project' AND target_id=?) " +
144
+ "OR EXISTS (SELECT 1 FROM json_each(payload,'$.findings') f WHERE json_extract(f.value,'$.project')=?)) " +
145
+ "AND (? IS NULL OR status=?) ORDER BY id DESC LIMIT ? OFFSET ?")
146
+ .all(EVIDENCE_FINDINGS_KIND, projectId, projectId === null ? null : Number(projectId), projectId === null ? null : `project:${projectId}`, status ?? null, status === "reviewed" ? "approved" : (status ?? null), limit + 1, offset);
139
147
  return { items: ids.slice(0, limit).map((row) => view(load(String(row.id)))), hasMore: ids.length > limit };
140
148
  },
141
149
  review: async (id, decision) => write(database, () => {
@@ -11,8 +11,8 @@ export declare const findingsAgent: (agent: FindingsAgent) => FindingsAgent;
11
11
  * including a reference it already saw for another finding.
12
12
  */
13
13
  export declare const findingsCoreOver: (context: StoreContext, kind: string) => {
14
- snapshot: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string, seen: Map<string, unknown>) => void) => string;
15
- snapshotParts: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string, seen: Map<string, unknown>) => void) => Record<string, string>;
14
+ snapshot: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string | null, seen: Map<string, unknown>) => void) => string;
15
+ snapshotParts: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string | null, seen: Map<string, unknown>) => void) => Record<string, string>;
16
16
  previous: (account: number | null, fingerprint: string) => FindingOutcome | undefined;
17
17
  outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent) => FindingOutcome[];
18
18
  };
@@ -30,15 +30,21 @@ export const findingsCoreOver = (context, kind) => {
30
30
  const cited = (input, cite) => {
31
31
  const seen = new Map();
32
32
  for (const finding of input.findings) {
33
- const project = requiredThing(database, finding.project, "the project");
34
- if (project.type !== "project")
35
- return invalid("a finding needs an owner project");
36
- const projectRow = database
37
- .prepare("SELECT id,key,name,description,type,scope,updated_at FROM projects WHERE id=? AND account_id IS NULL AND deleted_at IS NULL")
38
- .get(project.id);
39
- if (!projectRow)
40
- throw new CliError("not_found", "the owner project is unavailable");
41
- seen.set(finding.project, projectRow);
33
+ let projectId = null;
34
+ if (finding.project !== undefined) {
35
+ const project = requiredThing(database, finding.project, "the project");
36
+ if (project.type !== "project")
37
+ return invalid("a finding needs an owner project");
38
+ const projectRow = database
39
+ .prepare("SELECT id,key,name,description,type,scope,updated_at FROM projects WHERE id=? AND account_id IS NULL AND deleted_at IS NULL")
40
+ .get(project.id);
41
+ if (!projectRow)
42
+ throw new CliError("not_found", "the owner project is unavailable");
43
+ seen.set(finding.project, projectRow);
44
+ projectId = String(project.id);
45
+ }
46
+ if (finding.subject !== undefined)
47
+ seen.set(finding.subject, requiredThing(database, finding.subject, "the subject"));
42
48
  if (finding.kind === "task" && finding.assignee) {
43
49
  const person = requiredThing(database, finding.assignee, "the assignee");
44
50
  if (person.type !== "person")
@@ -49,7 +55,7 @@ export const findingsCoreOver = (context, kind) => {
49
55
  seen.set(finding.assignee, personRow);
50
56
  }
51
57
  for (const evidence of finding.evidence)
52
- cite(evidence, finding, String(project.id), seen);
58
+ cite(evidence, finding, projectId, seen);
53
59
  }
54
60
  return seen;
55
61
  };
@@ -68,12 +74,15 @@ export const findingsCoreOver = (context, kind) => {
68
74
  const fact = facts.add({
69
75
  type: finding.type,
70
76
  statement: finding.statement,
71
- project: finding.project,
77
+ ...(finding.project === undefined ? {} : { project: finding.project }),
78
+ ...(finding.subject === undefined ? {} : { subject: finding.subject }),
72
79
  evidence,
73
80
  by: agent,
74
81
  });
75
82
  return facts.accept(fact.ref).ref;
76
83
  }
84
+ if (finding.project === undefined)
85
+ return invalid("a task finding names the project the task goes in");
77
86
  const task = tasks.create({
78
87
  project: finding.project,
79
88
  title: finding.title,
@@ -6,8 +6,17 @@ export type TaskSort = (typeof TASK_SORTS)[number];
6
6
  export declare const PROJECT_TASK_FIELDS: readonly FieldSpec[];
7
7
  /** A `blocked-by` task that is not done holds the task back; so does one deleted or out of reach. */
8
8
  export declare const UNFINISHED_BLOCKER: string;
9
- /** `now` is when overdue is measured; one value per answer, so the list and its counts agree. */
10
- export declare const projectTaskColumns: (now: number) => ColumnMap;
9
+ /** Each due group as SQL over `t`, from day starts in the owner's zone; the filter and the counts share them. */
10
+ export declare const dueGroups: (now: number, zone: string) => {
11
+ readonly overdue: `t.status NOT IN ('done', 'dismissed') AND t.due_at IS NOT NULL AND t.due_at < ${number}`;
12
+ readonly today: `t.due_at >= ${number} AND t.due_at < ${number}`;
13
+ readonly upcoming: `t.due_at >= ${number} AND t.due_at < ${number}`;
14
+ readonly later: `t.due_at >= ${number}`;
15
+ readonly none: "t.due_at IS NULL";
16
+ };
17
+ export type DueGroup = keyof ReturnType<typeof dueGroups>;
18
+ /** `now` and `zone` place today; one value per answer, so the list and its counts agree. */
19
+ export declare const projectTaskColumns: (now: number, zone: string) => ColumnMap;
11
20
  /** Live tasks of the owner's projects; an account's inbox is listed by its account, never here. */
12
21
  export declare const BOARD_SCOPE = "t.deleted_at IS NULL AND t.project_id IN (SELECT id FROM projects WHERE deleted_at IS NULL AND length(key) <> 10)";
13
22
  /** The chosen order first, then priority, age and id, so a page boundary never splits a tie. */
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { OPERATORS_BY_TYPE, walkQuery, } from "@wirecat/cli-base/query";
2
+ import { dayBoundary, OPERATORS_BY_TYPE, walkQuery, } from "@wirecat/cli-base/query";
3
3
  import { TASK_TYPES } from "../../domain/knowledge.js";
4
4
  import { taskWordsOf } from "../../search/task-similarity.js";
5
5
  import { PRIORITY_LABELS, priorityBand, TASK_STATES } from "../../tasks/index.js";
@@ -28,7 +28,12 @@ export const PROJECT_TASK_FIELDS = [
28
28
  }),
29
29
  field("agent", "kw", "agent:hermes", { relation: "the agent working on it, by name; none or any" }),
30
30
  field("due", "time", "due:[today TO 2026-12-31]", { relation: "the due date; NOT due:* has none" }),
31
- field("overdue", "bool", "overdue:true", { relation: "open work whose due date has passed" }),
31
+ field("overdue", "bool", "overdue:true", { relation: "open work due before today" }),
32
+ field("when", "enum", "when:today", {
33
+ values: ["overdue", "today", "upcoming", "later", "none"],
34
+ relation: "the due date as a board groups it: overdue (open work due before today), today, upcoming (the next 7 days " +
35
+ "after today), later, or none",
36
+ }),
32
37
  field("closed", "time", "closed:>=14d", { relation: "when it was done or dismissed" }),
33
38
  field("ready", "bool", "ready:true", { relation: "open work whose blocked-by tasks are all done" }),
34
39
  field("text", "txt", "text:invoice", { relation: "words of the title and description; a bare word means this" }),
@@ -131,8 +136,28 @@ const truth = (node, sql) => ({
131
136
  sql: node.value.toLowerCase() === "true" ? sql : `NOT (${sql})`,
132
137
  params: [],
133
138
  });
134
- /** `now` is when overdue is measured; one value per answer, so the list and its counts agree. */
135
- export const projectTaskColumns = (now) => ({
139
+ const SPAN = { start: 0, end: 0 };
140
+ const UPCOMING_DAYS = 7;
141
+ const dayIn = (at, zone, ahead) => {
142
+ const date = new Date(`${new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at)}T00:00:00Z`);
143
+ date.setUTCDate(date.getUTCDate() + ahead);
144
+ return dayBoundary(date.toISOString().slice(0, 10), zone, false, SPAN);
145
+ };
146
+ /** Each due group as SQL over `t`, from day starts in the owner's zone; the filter and the counts share them. */
147
+ export const dueGroups = (now, zone) => {
148
+ const today = dayIn(now, zone, 0);
149
+ const tomorrow = dayIn(now, zone, 1);
150
+ const after = dayIn(now, zone, UPCOMING_DAYS + 1);
151
+ return {
152
+ overdue: `${OPEN} AND t.due_at IS NOT NULL AND t.due_at < ${today}`,
153
+ today: `t.due_at >= ${today} AND t.due_at < ${tomorrow}`,
154
+ upcoming: `t.due_at >= ${tomorrow} AND t.due_at < ${after}`,
155
+ later: `t.due_at >= ${after}`,
156
+ none: "t.due_at IS NULL",
157
+ };
158
+ };
159
+ /** `now` and `zone` place today; one value per answer, so the list and its counts agree. */
160
+ export const projectTaskColumns = (now, zone) => ({
136
161
  state: { type: "enum", sql: "t.status" },
137
162
  kind: { type: "enum", sql: "t.type" },
138
163
  project: { where: projectWhere },
@@ -141,7 +166,15 @@ export const projectTaskColumns = (now) => ({
141
166
  agent: { where: agentWhere },
142
167
  due: { type: "time", sql: "t.due_at" },
143
168
  closed: { type: "time", sql: "t.closed_at" },
144
- overdue: { where: (node) => truth(node, `${OPEN} AND t.due_at IS NOT NULL AND t.due_at < ${Number(now)}`) },
169
+ overdue: { where: (node) => truth(node, dueGroups(now, zone).overdue) },
170
+ when: {
171
+ where: (node) => {
172
+ const group = dueGroups(now, zone)[node.value.toLowerCase()];
173
+ if (group === undefined)
174
+ throw new CliError("validation_error", "when is overdue, today, upcoming, later or none");
175
+ return { sql: group, params: [] };
176
+ },
177
+ },
145
178
  ready: { where: (node) => truth(node, `${OPEN} AND NOT EXISTS (${UNFINISHED_BLOCKER})`) },
146
179
  text: {
147
180
  where: (node) => {
@@ -6,7 +6,7 @@ import type { Actor } from "./actors.js";
6
6
  import { type Author } from "./memories.js";
7
7
  import type { StoreContext } from "./open.js";
8
8
  import { type Where } from "./query-filter.js";
9
- import { type TaskSort } from "./task-board.js";
9
+ import { type DueGroup, type TaskSort } from "./task-board.js";
10
10
  import { type CloseSuggestion } from "./task-close.js";
11
11
  import { type TaskRelationsStore } from "./task-relations.js";
12
12
  export declare const TASK_ROLES: readonly ["assignee", "reviewer", "watcher"];
@@ -31,7 +31,7 @@ export interface TaskFields {
31
31
  /** A name the caller checked against its category list; every change is a `category` event. */
32
32
  category?: string;
33
33
  }
34
- export interface NewWorkTask extends Omit<TaskFields, "status" | "reason"> {
34
+ export interface NewWorkTask extends Omit<TaskFields, "reason"> {
35
35
  /** `project:<id>` or the project's key. */
36
36
  project: string;
37
37
  title: string;
@@ -48,6 +48,9 @@ export interface TaskDetails {
48
48
  ref: string;
49
49
  key: string;
50
50
  project: string;
51
+ /** The project's key and name, as `boardCounts` names it. */
52
+ projectKey: string;
53
+ projectName: string;
51
54
  title: string;
52
55
  description: string | null;
53
56
  type: TaskType;
@@ -109,8 +112,10 @@ export interface BoardOptions {
109
112
  /** How many tasks match, and how many hold each value: the board's pickers show these beside their choices. */
110
113
  export interface BoardCounts {
111
114
  total: number;
115
+ /** `value` is the key; `ref` the `project:<id>` a task's `project` names. */
112
116
  project: {
113
117
  value: string;
118
+ ref: string;
114
119
  name: string;
115
120
  count: number;
116
121
  }[];
@@ -129,6 +134,16 @@ export interface BoardCounts {
129
134
  value: TaskState;
130
135
  count: number;
131
136
  }[];
137
+ /** The groups `when:` takes, in board order; they do not overlap. */
138
+ due: {
139
+ value: DueGroup;
140
+ count: number;
141
+ }[];
142
+ /** The agent's name, or `none`. */
143
+ agent: {
144
+ value: string;
145
+ count: number;
146
+ }[];
132
147
  }
133
148
  export interface TaskEvent {
134
149
  kind: (typeof TASK_EVENT_KINDS)[number];
@@ -176,7 +191,10 @@ export interface WorkTasks extends TaskRelationsStore {
176
191
  total: number;
177
192
  }>;
178
193
  /** The owner and everyone who holds a role on a live task, by name: who the board offers to assign. */
179
- taskPeople(): Promise<{
194
+ /** Everyone the store knows, the owner first, then by how many live tasks name them. */
195
+ taskPeople(options?: {
196
+ limit?: number;
197
+ }): Promise<{
180
198
  person: string;
181
199
  name: string | null;
182
200
  owner: boolean;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { timezoneOf } from "@wirecat/cli-base/query";
2
3
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
3
4
  import { checkedScore, isClosedState, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, TASK_STATES, } from "../../tasks/index.js";
4
5
  import { folded } from "./folded.js";
@@ -7,7 +8,7 @@ import { CORPORA, drainCorpus } from "./note-index.js";
7
8
  import { requiredThing } from "./notes.js";
8
9
  import { queryFilter } from "./query-filter.js";
9
10
  import { stemmerCache } from "./stems.js";
10
- import { BOARD_SCOPE, boardOrder, projectTaskColumns, withoutField } from "./task-board.js";
11
+ import { BOARD_SCOPE, boardOrder, dueGroups, projectTaskColumns, withoutField, } from "./task-board.js";
11
12
  import { closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-close.js";
12
13
  import { INBOX_KEY_LENGTH, inboxKey } from "./task-keys.js";
13
14
  import { taskRelationsStoreOver } from "./task-relations.js";
@@ -53,6 +54,13 @@ const textOf = (value, what, max) => {
53
54
  const claimOf = (row, at) => row.claimed_by != null && Number(row.claimed_until) >= at
54
55
  ? { claimedBy: String(row.claimed_by), claimedUntil: iso(row.claimed_until) }
55
56
  : { claimedBy: null, claimedUntil: null };
57
+ /** A change to a time column is kept as epoch ms; it reads as ISO, as the task itself does. */
58
+ const timesAsIso = (changes) => Object.fromEntries(Object.entries(changes).map(([key, pair]) => [
59
+ key,
60
+ key.endsWith("_at") && Array.isArray(pair)
61
+ ? pair.map((value) => (typeof value === "number" ? new Date(value).toISOString() : value))
62
+ : pair,
63
+ ]));
56
64
  /** What a write made on a stale copy is refused with: the revision the caller read is not the task's now. */
57
65
  export const checkRevision = (row, expected) => {
58
66
  if (expected === undefined || Number(row.revision) === expected)
@@ -101,6 +109,10 @@ export const workTaskOperationsOver = (context) => {
101
109
  }
102
110
  return parent;
103
111
  };
112
+ const projectNamed = (id) => {
113
+ const project = database.prepare("SELECT key, name FROM projects WHERE id = ?").get(id);
114
+ return { projectKey: String(project.key), projectName: String(project.name) };
115
+ };
104
116
  const detailsOf = (row) => {
105
117
  const id = Number(row.id);
106
118
  const reference = (taskRow) => `task:${taskIdOf(database, taskRow)}`;
@@ -109,6 +121,7 @@ export const workTaskOperationsOver = (context) => {
109
121
  ref: reference(id),
110
122
  key: String(row.key),
111
123
  project: `project:${row.project_id}`,
124
+ ...projectNamed(Number(row.project_id)),
112
125
  title: String(row.title),
113
126
  description: row.description == null ? null : String(row.description),
114
127
  type: row.type,
@@ -141,7 +154,7 @@ export const workTaskOperationsOver = (context) => {
141
154
  const boardWhere = (query, zone, at) => {
142
155
  if (query === undefined)
143
156
  return { sql: BOARD_SCOPE, params: [] };
144
- const where = queryFilter(query, projectTaskColumns(at), {
157
+ const where = queryFilter(query, projectTaskColumns(at, timezoneOf(zone)), {
145
158
  command: "tasks list",
146
159
  now: at,
147
160
  ...(zone ? { zone } : {}),
@@ -483,8 +496,13 @@ export const workTaskOperationsOver = (context) => {
483
496
  .prepare(`SELECT ${select}, count(*) AS n FROM tasks t ${from} WHERE ${where.sql} GROUP BY ${group}`)
484
497
  .all(...where.params);
485
498
  };
486
- const project = grouped("project", "p.key, p.name", "JOIN projects p ON p.id = t.project_id", "p.id")
487
- .map((row) => ({ value: String(row.key), name: String(row.name), count: Number(row.n) }))
499
+ const project = grouped("project", "p.id, p.key, p.name", "JOIN projects p ON p.id = t.project_id", "p.id")
500
+ .map((row) => ({
501
+ value: String(row.key),
502
+ ref: `project:${row.id}`,
503
+ name: String(row.name),
504
+ count: Number(row.n),
505
+ }))
488
506
  .sort((a, b) => a.value.localeCompare(b.value));
489
507
  const assigned = grouped("assignee", "a.assignee_id AS person, pe.name", "LEFT JOIN task_assignments a ON a.task_id = t.id AND a.assignee_type = 'person' AND a.role = 'assignee' " +
490
508
  "LEFT JOIN persons pe ON pe.id = a.assignee_id", "a.assignee_id");
@@ -501,6 +519,17 @@ export const workTaskOperationsOver = (context) => {
501
519
  priorities.set(label, (priorities.get(label) ?? 0) + Number(row.n));
502
520
  }
503
521
  const states = new Map(grouped("state", "t.status", "", "t.status").map((row) => [row.status, Number(row.n)]));
522
+ const groups = dueGroups(at, timezoneOf(zone));
523
+ const dueWhere = boardWhere(withoutField(withoutField(query, "when"), "overdue"), zone, at);
524
+ const due = Object.keys(groups).map((value) => ({
525
+ value,
526
+ count: Number(database
527
+ .prepare(`SELECT count(*) AS n FROM tasks t WHERE ${dueWhere.sql} AND ${groups[value]}`)
528
+ .get(...dueWhere.params)?.n ?? 0),
529
+ }));
530
+ const agent = grouped("agent", "t.agent", "", "t.agent")
531
+ .map((row) => ({ value: row.agent == null ? "none" : String(row.agent), count: Number(row.n) }))
532
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
504
533
  return {
505
534
  total: countOf(boardWhere(query, zone, at)),
506
535
  project,
@@ -510,14 +539,16 @@ export const workTaskOperationsOver = (context) => {
510
539
  count: priorities.get(value) ?? 0,
511
540
  })),
512
541
  state: TASK_STATES.map((value) => ({ value, count: states.get(value) ?? 0 })),
542
+ due,
543
+ agent,
513
544
  };
514
545
  },
515
- taskPeople: async () => database
546
+ taskPeople: async ({ limit = 500 } = {}) => database
516
547
  .prepare("SELECT p.id, p.name, p.owner, count(DISTINCT t.id) AS n FROM persons p " +
517
548
  "LEFT JOIN task_assignments a ON a.assignee_type = 'person' AND a.assignee_id = p.id " +
518
549
  "LEFT JOIN tasks t ON t.id = a.task_id AND t.deleted_at IS NULL " +
519
- "WHERE p.owner = 1 OR t.id IS NOT NULL GROUP BY p.id ORDER BY p.owner DESC, p.name COLLATE NOCASE, p.id")
520
- .all()
550
+ "GROUP BY p.id ORDER BY p.owner DESC, n DESC, p.name IS NULL, p.name COLLATE NOCASE, p.id LIMIT ?")
551
+ .all(Math.min(Math.max(limit, 1), 5000))
521
552
  .map((row) => ({
522
553
  person: `person:${row.id}`,
523
554
  name: row.name == null ? null : String(row.name),
@@ -539,7 +570,7 @@ export const workTaskOperationsOver = (context) => {
539
570
  id: Number(row.actor_id),
540
571
  name: row.actor_name == null ? null : String(row.actor_name),
541
572
  },
542
- changes: row.changes == null ? null : JSON.parse(String(row.changes)),
573
+ changes: row.changes == null ? null : timesAsIso(JSON.parse(String(row.changes))),
543
574
  at: iso(row.created_at),
544
575
  })),
545
576
  };
@@ -33,7 +33,11 @@ export const priorityOf = (value) => {
33
33
  return MIDDLE[word];
34
34
  if (/^[0-4]$/.test(word))
35
35
  return LEGACY_PRIORITY_SCORES[Number(word)];
36
- throw new CliError("validation_error", `a priority is ${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still work as critical, high, medium, low, low`);
36
+ // A bare 0 or 1 is the old scale, where they mean the opposite; the decimal point says a 0–1 score is meant.
37
+ if (/^(0?\.\d+|[01]\.\d*)$/.test(word) && Number(word) <= 1)
38
+ return Number(word);
39
+ throw new CliError("validation_error", `a priority is ${PRIORITY_LABELS.join(", ")}, none, or a score from 0.0 to 1.0 with its decimal point; ` +
40
+ "0 (urgent) … 4 (low) still work as critical, high, medium, low, low");
37
41
  };
38
42
  /** `priorityOf` for a command-line option: Commander stores a parser's `null` as an empty string, so `none` stays a word. */
39
43
  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.527.0",
3
+ "version": "0.529.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",
@@ -251,7 +251,7 @@
251
251
  "@types/node": "^22.20.5",
252
252
  "@types/pngjs": "6.0.5",
253
253
  "@vitest/coverage-v8": "^5.0.5",
254
- "@wirecat/cli-base": "0.68.0",
254
+ "@wirecat/cli-base": "0.71.0",
255
255
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
256
256
  "commander": "15.0.0",
257
257
  "cspell": "10.4.0",
@@ -271,7 +271,7 @@
271
271
  },
272
272
  "peerDependencies": {
273
273
  "@napi-rs/canvas": ">=0.1.69 <2",
274
- "@wirecat/cli-base": ">=0.68.0 <0.69.0",
274
+ "@wirecat/cli-base": ">=0.71.0 <0.72.0",
275
275
  "commander": ">=15"
276
276
  },
277
277
  "bin": {