@wirecat/cli-base 0.47.5 → 0.49.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
@@ -8,6 +8,45 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.49.0 — 21.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`parseLucene` can read a phrase's distance.** With `{ proximity: true }`, `"a b"~3` parses as a phrase predicate
16
+ with `slop: 3`, and `queryNodeSchema` accepts `slop`. Meeting search needs it for words spread over neighbouring
17
+ transcript lines, and a caller that turns it on says what the number counts. Without the option `~` is still
18
+ `query.unsupported-operator`, so messages and lists see no change. A distance that is not a whole number is
19
+ `query.invalid-syntax`; `word~2` (fuzzy) stays unsupported either way.
20
+
21
+ ## 0.48.0 — 21.10.2026
22
+
23
+ ### Changed — may break callers
24
+
25
+ - **The handbook has a Time page, and the shared CI runs tests in `Asia/Vladivostok`.** Every time is stored and
26
+ exchanged in UTC; a zone is applied only where a time with no zone comes in and where a person reads one
27
+ ([`docs/standards/time.md`](docs/standards/time.md), `WC-TIME-1`…`6`). `WC-OUT-13` now asks for ISO 8601 ending
28
+ in `Z`, not any zone. `node-ci.yml` sets `TZ=Asia/Vladivostok` for `test:coverage`, so a test that passed only
29
+ because CI ran in UTC fails. What to watch for: a repository that moves its `node-ci.yml` pin to this release may
30
+ see tests fail that read or print local dates; those are the bugs the Time page names.
31
+
32
+ ### Added
33
+
34
+ - **`wallTime(value, zone, span)` in `@wirecat/cli-base/query`** reads a time with no zone (`2026-10-21T16:03`,
35
+ optional seconds) as the moment it names in an IANA zone, whatever the process zone. A local time that daylight
36
+ saving skips or repeats is refused with `validation_error`, never guessed. It is the input edge `WC-TIME-3` asks
37
+ for; cli-core's `--since-time` and `--at-time` parsers are to move onto it.
38
+ - **`zonedMoment(value, zone, span, { end? })` in `@wirecat/cli-base/query`** reads a typed date or time the way
39
+ `WC-TIME-3` and `WC-TIME-5` ask: a zone the value names wins, a time without one is read in `zone`, a date alone is
40
+ local midnight, or with `end` the last millisecond of that local day.
41
+ - **`meta.timezone` and `meta.window`** are allowed in every envelope: the zone a time without one was read or shown
42
+ in, and the UTC `{ since?, until? }` a date filter used.
43
+
44
+ ### Fixed
45
+
46
+ - **`fakeClock(startMs).now()` starts at `startMs`.** It reported `2 × startMs` plus the time slept, so a test that
47
+ gave a start time saw a different wall time than it set. What to watch for: a test that worked around the doubled
48
+ value now sees the value it passed.
49
+
11
50
  ## 0.47.5 — 21.10.2026
12
51
 
13
52
  ### Fixed
@@ -1,6 +1,15 @@
1
1
  import { type Span } from "./types.js";
2
2
  export declare const timezoneOf: (given?: string) => string;
3
3
  export declare const dayBoundary: (value: string, zone: string, next: boolean, span: Span) => number;
4
+ /** A time with no zone, read in `zone`. A local time that daylight saving skips or repeats is refused. */
5
+ export declare const wallTime: (value: string, zone: string, span: Span) => number;
6
+ /**
7
+ * A typed date or time as epoch ms. A zone the value names wins; else it is read in `zone`. A date alone is the
8
+ * start of that local day, or with `end` its last millisecond, so an inclusive `<=` keeps the whole day.
9
+ */
10
+ export declare const zonedMoment: (value: string, zone: string, span: Span, { end }?: {
11
+ end?: boolean | undefined;
12
+ }) => number;
4
13
  export interface DateRange {
5
14
  lower?: number;
6
15
  upper?: number;
@@ -50,6 +50,60 @@ export const dayBoundary = (value, zone, next, span) => {
50
50
  queryError("query.invalid-date", span, "this calendar day does not exist in the selected timezone");
51
51
  return lo;
52
52
  };
53
+ const WALL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/u;
54
+ /** A time with no zone, read in `zone`. A local time that daylight saving skips or repeats is refused. */
55
+ export const wallTime = (value, zone, span) => {
56
+ const [, year, month, day, hour, minute, second = "0", fraction = "0"] = WALL.exec(value) ?? [];
57
+ if (year === undefined)
58
+ queryError("query.invalid-date", span, "use YYYY-MM-DDTHH:mm, with optional seconds");
59
+ const wanted = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Number(fraction.padEnd(3, "0")));
60
+ const check = new Date(wanted);
61
+ if (check.getUTCDate() !== Number(day) ||
62
+ check.getUTCHours() !== Number(hour) ||
63
+ check.getUTCMinutes() !== Number(minute) ||
64
+ check.getUTCSeconds() !== Number(second))
65
+ queryError("query.invalid-date", span);
66
+ const formatter = new Intl.DateTimeFormat("en-CA", {
67
+ timeZone: zone,
68
+ year: "numeric",
69
+ month: "2-digit",
70
+ day: "2-digit",
71
+ hour: "2-digit",
72
+ minute: "2-digit",
73
+ second: "2-digit",
74
+ hourCycle: "h23",
75
+ });
76
+ const shown = (instant) => {
77
+ const parts = Object.fromEntries(formatter.formatToParts(instant).map((part) => [part.type, Number(part.value)]));
78
+ return (Date.UTC(parts.year ?? 0, (parts.month ?? 1) - 1, parts.day, parts.hour, parts.minute, parts.second) +
79
+ (((instant % 1000) + 1000) % 1000));
80
+ };
81
+ const found = new Set();
82
+ for (const probe of [wanted - 86_400_000, wanted, wanted + 86_400_000]) {
83
+ const candidate = wanted - (shown(probe) - probe);
84
+ if (shown(candidate) === wanted)
85
+ found.add(candidate);
86
+ }
87
+ if (found.size !== 1)
88
+ queryError("query.invalid-date", span, "this local time is skipped or repeated by daylight saving; add an offset");
89
+ return [...found][0];
90
+ };
91
+ const ZONED = /(?:Z|[+-]\d{2}:?\d{2})$/iu;
92
+ /**
93
+ * A typed date or time as epoch ms. A zone the value names wins; else it is read in `zone`. A date alone is the
94
+ * start of that local day, or with `end` its last millisecond, so an inclusive `<=` keeps the whole day.
95
+ */
96
+ export const zonedMoment = (value, zone, span, { end = false } = {}) => {
97
+ if (/^\d{4}-\d{2}-\d{2}$/u.test(value))
98
+ return dayBoundary(value, zone, end, span) - (end ? 1 : 0);
99
+ if (!ZONED.test(value))
100
+ return wallTime(value, zone, span);
101
+ const time = Date.parse(value);
102
+ if (!/^\d{4}-\d{2}-\d{2}T/u.test(value) || !Number.isFinite(time))
103
+ queryError("query.invalid-date", span);
104
+ calendar(value.slice(0, 10), span);
105
+ return time;
106
+ };
53
107
  const AGO = /^(\d+)(m|h|d)$/u;
54
108
  const AGO_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
55
109
  const dayOf = (word, zone, now, span) => {
@@ -2,7 +2,7 @@
2
2
  * One query language for every list: Lucene syntax over the entity's own fields. The parser is a port of
3
3
  * Lucene 9.12.3's; the fields come from the entity schema; `evaluate` is a test oracle, never a list filter.
4
4
  */
5
- export { type DateRange, dateEndpoint, dateRange, dayBoundary, timezoneOf } from "./dates.js";
5
+ export { type DateRange, dateEndpoint, dateRange, dayBoundary, timezoneOf, wallTime, zonedMoment } from "./dates.js";
6
6
  export { type EvaluateOptions, evaluate } from "./evaluate.js";
7
7
  export { FIELD_TYPES, type FieldAnnotations, type FieldSpec, type FieldType, OPERATORS_BY_TYPE, QUERY_BACKENDS, type QueryBackend, type QueryRegistry, queryFieldsOf, } from "./fields.js";
8
8
  export { fieldsHelp, QUERY_SYNTAX } from "./help.js";
@@ -2,7 +2,7 @@
2
2
  * One query language for every list: Lucene syntax over the entity's own fields. The parser is a port of
3
3
  * Lucene 9.12.3's; the fields come from the entity schema; `evaluate` is a test oracle, never a list filter.
4
4
  */
5
- export { dateEndpoint, dateRange, dayBoundary, timezoneOf } from "./dates.js";
5
+ export { dateEndpoint, dateRange, dayBoundary, timezoneOf, wallTime, zonedMoment } from "./dates.js";
6
6
  export { evaluate } from "./evaluate.js";
7
7
  export { FIELD_TYPES, OPERATORS_BY_TYPE, QUERY_BACKENDS, queryFieldsOf, } from "./fields.js";
8
8
  export { fieldsHelp, QUERY_SYNTAX } from "./help.js";
@@ -2,7 +2,9 @@ import { type QueryAst, type QueryContext } from "./types.js";
2
2
  export interface ParseOptions extends QueryContext {
3
3
  /** The field a bare word searches; `none` for a list, where a bare word is `query.bare-word`. */
4
4
  defaultField?: string;
5
+ /** Read `"a b"~N` as a phrase with `slop: N`; a caller that turns it on says what N counts. */
6
+ proximity?: boolean;
5
7
  }
6
- export declare const parseLucene: (text: string, { defaultField, ...context }?: ParseOptions) => QueryAst;
8
+ export declare const parseLucene: (text: string, { defaultField, proximity, ...context }?: ParseOptions) => QueryAst;
7
9
  /** Bare terms eligible for forgiving retrieval; explicit syntax keeps strict semantics. */
8
10
  export declare const implicitTextTerms: (text: string) => Set<number>;
@@ -141,7 +141,7 @@ const regexHint = (text, at) => {
141
141
  const word = `${/[^\s()"]*$/u.exec(text.slice(0, at))?.[0] ?? ""}${/^[^\s()"]*/u.exec(text.slice(at))?.[0] ?? ""}`;
142
142
  return quoteHint(text, at) ?? `/ starts a regular expression; put the value in quotes: "${word}"`;
143
143
  };
144
- export const parseLucene = (text, { defaultField = "text", ...context } = {}) => {
144
+ export const parseLucene = (text, { defaultField = "text", proximity = false, ...context } = {}) => {
145
145
  const fail = (reason, span, alternative) => queryError(reason, span, alternative ?? quoteHint(text, span.start), context);
146
146
  if (text.length > QUERY_LIMITS.bytes || new TextEncoder().encode(text).length > QUERY_LIMITS.bytes)
147
147
  fail("query.too-large", { start: 0, end: text.length });
@@ -290,6 +290,13 @@ export const parseLucene = (text, { defaultField = "text", ...context } = {}) =>
290
290
  ...(implicit ? { implicit: true } : {}),
291
291
  });
292
292
  }
293
+ if (proximity && peek().kind === "~" && node.kind === "predicate" && node.operator === "phrase") {
294
+ take();
295
+ const distance = take();
296
+ if (distance.kind !== "number" || !/^\d+$/u.test(distance.raw))
297
+ fail("query.invalid-syntax", distance.span, 'write the distance as a whole number: "a b"~3');
298
+ node = { ...node, slop: Number(distance.raw), span: { start: node.span.start, end: distance.span.end } };
299
+ }
293
300
  if (peek().kind === "~")
294
301
  fail("query.unsupported-operator", peek().span, node.kind === "predicate" && node.operator === "phrase"
295
302
  ? "proximity (~) is not supported — drop the ~ for the exact phrase, or join the words with AND"
@@ -21,6 +21,8 @@ export interface Predicate {
21
21
  span: Span;
22
22
  /** A bare word, under no field. */
23
23
  implicit?: true;
24
+ /** `"a b"~N`, read only when the parser is asked to: how far apart the phrase's words may be. */
25
+ slop?: number;
24
26
  }
25
27
  export type QueryNode = Predicate | {
26
28
  kind: "boolean";
@@ -16,6 +16,7 @@ const predicateSchema = v.object({
16
16
  upper_inclusive: v.optional(v.boolean()),
17
17
  span: spanSchema,
18
18
  implicit: v.optional(v.literal(true)),
19
+ slop: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
19
20
  });
20
21
  export const queryNodeSchema = v.variant("kind", [
21
22
  predicateSchema,
@@ -4,7 +4,7 @@ const fieldRuleSchema = v.strictObject({ id: word, rule: text });
4
4
  export const defineFieldRules = (input) => parseRegistry("output field rules", v.array(fieldRuleSchema), input, (rules) => duplicates(rules.map((rule) => rule.id)).map((id) => `"${id}" is listed twice`));
5
5
  export const FIELD_RULES = defineFieldRules([
6
6
  { id: "envelope", rule: "every JSON answer is one object: a list `{items, page, …}`, one thing `{item, …}`" },
7
- { id: "time", rule: "a time is an ISO 8601 string with a zone, never epoch milliseconds" },
7
+ { id: "time", rule: "a time is an ISO 8601 string in UTC, ending in `Z`, never epoch milliseconds" },
8
8
  { id: "duration", rule: "a length of time is a number of milliseconds in a key ending in `_ms`" },
9
9
  { id: "ref", rule: "a stored thing is named by `ref`; a message's `ref` value is its locator" },
10
10
  { id: "snake-case", rule: "keys are snake_case words, no abbreviations a reader has to decode" },
@@ -7,7 +7,7 @@ export declare const LOG_SEVERITIES: readonly ["trace", "debug", "info", "warn",
7
7
  * (https://opentelemetry.io/docs/specs/otel/compatibility/logging_trace_context/); `run_id` and `command` are ours.
8
8
  */
9
9
  export declare const logRecordSchema: v.StrictObjectSchema<{
10
- readonly time: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time with a zone">]>;
10
+ readonly time: 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`">]>;
11
11
  readonly severity: v.PicklistSchema<readonly ["trace", "debug", "info", "warn", "error", "fatal"], undefined>;
12
12
  /** The registered event id, `provider.request.failed`: never a sentence, so never content. */
13
13
  readonly body: v.StringSchema<undefined>;
@@ -1,7 +1,7 @@
1
1
  import * as v from "valibot";
2
2
  /** At most two next steps per answer: the one to take, then one alternative. */
3
3
  export declare const MAX_NEXT_STEPS = 2;
4
- export declare const isoTime: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time with a zone">]>;
4
+ export declare const isoTime: 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`">]>;
5
5
  /** The typed name of a stored thing, `msg:telegram/1/2/3` or `note:42`: what every other command takes. */
6
6
  export declare const reference: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
7
7
  export declare const nextStepSchema: v.VariantSchema<"kind", [v.StrictObjectSchema<{
@@ -65,6 +65,11 @@ export declare const metaSchema: v.StrictObjectSchema<{
65
65
  readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
66
66
  }, undefined>, undefined>, undefined>;
67
67
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
68
+ readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
69
+ readonly window: v.OptionalSchema<v.StrictObjectSchema<{
70
+ readonly since: v.OptionalSchema<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>;
71
+ readonly until: v.OptionalSchema<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>;
72
+ }, undefined>, undefined>;
68
73
  }, undefined>;
69
74
  export declare const listEnvelopeSchema: <T extends v.GenericSchema>(item: T) => v.StrictObjectSchema<{
70
75
  readonly items: v.ArraySchema<T, undefined>;
@@ -132,6 +137,11 @@ export declare const listEnvelopeSchema: <T extends v.GenericSchema>(item: T) =>
132
137
  readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
133
138
  }, undefined>, undefined>, undefined>;
134
139
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
140
+ readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
141
+ readonly window: v.OptionalSchema<v.StrictObjectSchema<{
142
+ readonly since: v.OptionalSchema<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>;
143
+ readonly until: v.OptionalSchema<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>;
144
+ }, undefined>, undefined>;
135
145
  }, undefined>;
136
146
  }, undefined>;
137
147
  export declare const itemEnvelopeSchema: <T extends v.GenericSchema>(item: T) => v.StrictObjectSchema<{
@@ -189,6 +199,11 @@ export declare const itemEnvelopeSchema: <T extends v.GenericSchema>(item: T) =>
189
199
  readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
190
200
  }, undefined>, undefined>, undefined>;
191
201
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
202
+ readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
203
+ readonly window: v.OptionalSchema<v.StrictObjectSchema<{
204
+ readonly since: v.OptionalSchema<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>;
205
+ readonly until: v.OptionalSchema<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>;
206
+ }, undefined>, undefined>;
192
207
  }, undefined>;
193
208
  }, undefined>;
194
209
  export type NextStep = v.InferOutput<typeof nextStepSchema>;
@@ -3,7 +3,7 @@ import { errorCodes } from "../errors.js";
3
3
  import { dottedId, text } from "./registry.js";
4
4
  /** At most two next steps per answer: the one to take, then one alternative. */
5
5
  export const MAX_NEXT_STEPS = 2;
6
- export const isoTime = v.pipe(v.string(), v.isoTimestamp("an ISO 8601 time with a zone"));
6
+ export const isoTime = v.pipe(v.string(), v.isoTimestamp("an ISO 8601 time in UTC"), v.endsWith("Z", "an ISO 8601 time in UTC, ending in `Z`"));
7
7
  /** The typed name of a stored thing, `msg:telegram/1/2/3` or `note:42`: what every other command takes. */
8
8
  export const reference = v.pipe(v.string(), v.regex(/^[a-z]+:\S+$/, "a reference such as `note:42`"));
9
9
  export const nextStepSchema = v.variant("kind", [
@@ -65,6 +65,8 @@ export const metaSchema = v.strictObject({
65
65
  complete: v.boolean(),
66
66
  issues: v.optional(v.array(issueSchema)),
67
67
  truncated: v.optional(v.boolean()),
68
+ timezone: v.optional(text),
69
+ window: v.optional(v.strictObject({ since: v.optional(isoTime), until: v.optional(isoTime) })),
68
70
  });
69
71
  const nextSteps = v.optional(v.pipe(v.array(nextStepSchema), v.maxLength(MAX_NEXT_STEPS)));
70
72
  export const listEnvelopeSchema = (item) => v.strictObject({
@@ -22,7 +22,7 @@ export const fakeClock = (startMs = 0) => {
22
22
  return {
23
23
  waits,
24
24
  clock: () => elapsed,
25
- now: () => new Date(startMs + elapsed),
25
+ now: () => new Date(elapsed),
26
26
  sleep: async (ms, signal, reason) => {
27
27
  if (signal?.aborted)
28
28
  throw new DOMException("The operation was aborted.", "AbortError");
@@ -1,7 +1,7 @@
1
1
  # WireCat CLI standards
2
2
 
3
3
  This handbook holds the rules every WireCat CLI follows: tools, layout, commands, output, MCP, accounts,
4
- permissions, architecture, tests, security, documents, releases and CI. Read this page first. Then read the page
4
+ permissions, architecture, time, tests, security, documents, releases and CI. Read this page first. Then read the page
5
5
  for the area you are about to change.
6
6
 
7
7
  It ships in the `@wirecat/cli-base` package. A CLI that depends on cli-base has it at
@@ -40,6 +40,7 @@ rules it keeps. A new CLI is ready for its first release when every check under
40
40
  | [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
41
41
  | [Releases](releases.md) | `REL` | 16 | `bin/release`, the release lock, versions, release reports, trusted publishing |
42
42
  | [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
43
+ | [Time](time.md) | `TIME` | 6 | UTC in the store and in answers, the zone at input and display edges, date-only filters, tests in another zone |
43
44
  | [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
44
45
  | [New CLI](new-cli.md) | — | — | The checklist for starting a new CLI |
45
46
 
@@ -80,7 +80,9 @@ JSON Schema 2020-12.
80
80
 
81
81
  - **Why:** `run_id` finds the run later, and `complete` says whether the answer can be trusted as whole.
82
82
  - **Enforced by:** review only.
83
- - **Example:** `{ run_id, duration_ms, accounts?, complete, issues?, truncated? }`. `complete: false` with
83
+ - **Example:** `{ run_id, duration_ms, accounts?, complete, issues?, truncated?, timezone?, window? }`. `timezone`
84
+ names the zone a time without one was read or shown in, and `window` the UTC bounds a date filter used
85
+ ([Time](time.md)). `complete: false` with
84
86
  `issues: [{ code, reason?, source }]` says a source failed or was skipped. `truncated` says a size cap cut the
85
87
  answer. Neither one means another page exists: that is `page.has_more`.
86
88
  - **Exceptions:** none.
@@ -147,7 +149,7 @@ JSON Schema 2020-12.
147
149
 
148
150
  - **Why:** a caller must not guess whether `0` means "none" or "unknown".
149
151
  - **Enforced by:** `nullable-list`.
150
- - **Example:** a time is an ISO 8601 string with a zone, never epoch milliseconds. A length of time is a number of
152
+ - **Example:** a time is an ISO 8601 string in UTC, ending in `Z`, never epoch milliseconds ([Time](time.md)). A length of time is a number of
151
153
  milliseconds in a key ending in `_ms`. A stored thing is named by `ref`. A key that is absent was not asked for or
152
154
  is not known. `null` is known to have no value. `[]` is known to be empty, never `null`. `0` is measured, never a
153
155
  stand-in for unknown.
@@ -0,0 +1,97 @@
1
+ # Time
2
+
3
+ ## Scope
4
+
5
+ How a CLI keeps, exchanges, reads and shows a moment in time: UTC inside, a zone only at the edges.
6
+
7
+ Rule ids on this page are `WC-TIME-<n>`. How to read a rule, how ids work and which rule wins are in the
8
+ [index](README.md).
9
+
10
+ Related pages: how a time is written in an answer, next to durations and empty values, is in [Output](output.md)
11
+ (`WC-OUT-13`); the store and its migrations are in [Store](store.md); the test sandbox is in [Testing](testing.md).
12
+
13
+ ### Words used here
14
+
15
+ - **Edge:** a place where a time enters without a zone (a typed date, a file name, a provider field) or leaves for
16
+ a person to read (human text output).
17
+ - **Zone in use:** the `--timezone` flag when given, else the zone a setting names, else the computer's IANA zone.
18
+
19
+ ## Rules
20
+
21
+ ### WC-TIME-1 · Store every time in UTC
22
+
23
+ Every time in a database, cache, cursor, receipt, manifest or exported file is UTC: an ISO 8601 string ending in
24
+ `Z`, or epoch milliseconds. Never local wall time, never an offset other than zero.
25
+
26
+ - **Why:** a stored time read on another computer, or after the owner travels, must mean the same moment.
27
+ - **Enforced by:** review only.
28
+ - **Example:** `sent_at INTEGER` holding `1792591380000`, or `"2026-10-21T14:03:00.000Z"`.
29
+ - **Exceptions:** a column that already holds UTC in another unambiguous form (SQLite's `YYYY-MM-DD HH:MM:SS` from
30
+ `CURRENT_TIMESTAMP`, epoch seconds) stays until its table next migrates; its value still leaves as `WC-TIME-2`
31
+ says. A column that may hold local time gets a forward-only migration, or a proof from history that every writer
32
+ wrote UTC.
33
+ - **Applies to:** all CLIs, cli-core and cli-base. **Since:** cli-base 0.48.0. **Rulings:** NEED-1592.
34
+
35
+ ### WC-TIME-2 · Exchange every time in UTC, and add a local time only beside it
36
+
37
+ JSON, JSONL and MCP output carry each time as an ISO 8601 string ending in `Z`. A display zone may add a second key,
38
+ `<key>_local`, with the offset of the zone in use, and `meta.timezone` then names that zone. The local key never
39
+ replaces the UTC one.
40
+
41
+ - **Why:** an agent compares and sorts times from many CLIs; one form, in one zone, needs no guessing.
42
+ - **Enforced by:** review only.
43
+ - **Example:** `{ "sent_at": "2026-10-21T14:03:00.000Z", "sent_at_local": "2026-10-21T16:03:00.000+02:00" }` with
44
+ `"meta": { "timezone": "Europe/Madrid", … }`.
45
+ - **Exceptions:** a provider's own text kept as it came (a mail `Date:` header) goes under `provider_metadata`.
46
+ - **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
47
+
48
+ ### WC-TIME-3 · Read a time that has no zone in the zone in use, and say which zone that was
49
+
50
+ A typed date, a file name or a provider field without a zone is read in the zone in use. The answer says that zone:
51
+ `meta.timezone` in machine mode, a short line in human text.
52
+
53
+ - **Why:** a silent default turns a wall-clock time into the wrong moment; zm stored 111 meetings two hours late when
54
+ file names were read as UTC.
55
+ - **Enforced by:** review only.
56
+ - **Example:** `zm import 2026-10-21_1603_standup.vtt` on a computer in `Europe/Madrid` stores `14:03Z`, and says
57
+ "file times read in Europe/Madrid".
58
+ - **Exceptions:** a provider documented to send UTC without a marker is read as UTC, with a link to that
59
+ documentation next to the code. A local time that daylight saving makes ambiguous or nonexistent is refused, never
60
+ guessed.
61
+ - **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** NEED-1592.
62
+
63
+ ### WC-TIME-4 · Show times to a person in the zone in use, and say which zone
64
+
65
+ - **Why:** a person reads wall-clock time; a UTC time shown without a word reads as local.
66
+ - **Enforced by:** review only.
67
+ - **Example:** `messages list` prints `16:03` and ends with `times in Europe/Madrid`.
68
+ - **Exceptions:** none.
69
+ - **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
70
+
71
+ ### WC-TIME-5 · A date with no time covers the whole local day, and the answer gives the window in UTC
72
+
73
+ `--since-time 2026-09-01` starts at local midnight of the zone in use; `--until-time 2026-09-01` ends at the next
74
+ local midnight. The answer gives the window it used, in UTC.
75
+
76
+ - **Why:** "messages of 1 September" means the owner's day, not the UTC day, which starts hours earlier or later.
77
+ - **Enforced by:** review only.
78
+ - **Example:** in `Asia/Vladivostok`, `--since-time 2026-09-01` gives `meta.window.since` = `2026-08-31T14:00:00.000Z`.
79
+ - **Exceptions:** none.
80
+ - **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
81
+
82
+ ### WC-TIME-6 · Run the tests in a zone that is not UTC, and prove stored values do not depend on it
83
+
84
+ The shared `node-ci.yml` runs `test:coverage` with `TZ=Asia/Vladivostok` (UTC+10, no daylight saving). Each
85
+ repository that stores times has a test that writes the same input under two zones and gets identical stored values.
86
+
87
+ - **Why:** a suite that runs only in UTC passes for every bug on this page.
88
+ - **Enforced by:** `node-ci.yml`.
89
+ - **Example:** set `process.env.TZ` to `Asia/Vladivostok`, then to `America/Los_Angeles`, write the same message each
90
+ time, and compare the rows. Node applies a changed `TZ` at once.
91
+ - **Exceptions:** a repository that keeps no time skips the second test.
92
+ - **Applies to:** all CLIs, cli-core and cli-base. **Since:** cli-base 0.48.0. **Rulings:** none.
93
+
94
+ ## Sources
95
+
96
+ - The owner's ruling of 21.10.2026: every time is stored and exchanged in UTC, in every repository.
97
+ - NEED-1592: zm reads file name times in the computer's zone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.47.5",
3
+ "version": "0.49.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",