@wirecat/cli-base 0.50.4 → 0.51.1

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,37 @@ 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.51.1 — 24.10.2026
12
+
13
+ ### Fixed
14
+
15
+ - The shared vocabulary recognizes `--draft` as a switch that saves text for review without sending. Enabling an adapter’s draft capability no longer creates an unknown-option-role lint failure.
16
+
17
+ ### Removed
18
+
19
+ - **No wait between releases and no weekly limit on breaking changes** (`WC-REL-8`, [Releases](docs/standards/releases.md)). A messenger CLI no longer waits two hours after its last version, and a shared library may ship a change that makes the CLIs change their code at any time. `WC-REL-9` is merged into `WC-REL-8`. The release lock stays. Nothing changes in code.
20
+
21
+ ## 0.51.0 — 24.10.2026
22
+
23
+ ### Added
24
+
25
+ - **Comparison pages live in the site repository, and our marks are re-checked at each release** (`WC-CMP-11`, [Comparisons](docs/standards/comparisons.md)). The page is written in `cli-docs` (wirecat.dev) because a comparison changes when other tools change, not when we release; the CLI repository keeps a short `docs/compare.md` that links to it. The release report lists the compare page as checked. Nothing changes in code.
26
+
27
+ ### Changed — may break callers
28
+
29
+ - **Shared command contracts require explicit overrides before host edits.** `sharedCommand`, `assertSharedContracts`, `overrideSharedContract` and `customizeSharedCommand` live in `/commands`; original definitions and exact accepted edits remain available for audits. Independent shell replacements must supply static, exact before/after declarations; a new Core default or a changed provider builder fails until those declarations are reviewed. Provider additions are allowed; inherited subcommands cannot disappear silently. `/commands` now loads Commander at runtime.
30
+
31
+ - **Cross-CLI contract drift cannot be accepted through a lint baseline.** Declare intentional differences with
32
+ their exact expected values and a specific reason; a changed or stale exemption fails. `audit --complete`
33
+ collects actual schemas and error reasons from `commands info --json` and refuses missing coverage. Canonical capability manifests and validated inherited definitions distinguish explicit provider variants from drift. Global
34
+ options satisfy matching command-local controls, so their placement alone is no longer a divergence.
35
+
36
+ ### Fixed
37
+
38
+ - **Complete audits keep runtime availability separate from static contracts.** A currently denied or available command retains the same protected grammar and output definition.
39
+
40
+ - **Command lint distinguishes list filters from computation and output controls.** Ranking weights, timezone, output context and byte pagination remain controls even when an answer exposes similarly named fields; singleton targets are not list filters. Native query fields and id/time filtering remain checked.
41
+
11
42
  ## 0.50.4 — 24.10.2026
12
43
 
13
44
  ### Added
@@ -0,0 +1,19 @@
1
+ import { Command } from "commander";
2
+ export interface SharedContractOverride {
3
+ command: readonly string[];
4
+ field: string;
5
+ before: unknown;
6
+ after: unknown;
7
+ reason: string;
8
+ }
9
+ export declare const hasSharedContract: (command: Command) => boolean;
10
+ export declare const assertSharedContracts: (root: Command) => void;
11
+ /** The callback names the deliberate edit; an already changed contract cannot be acknowledged after the fact. */
12
+ export declare const overrideSharedContract: <T extends Command>(command: T, reason: string, change: (command: T) => void) => T;
13
+ export declare const sharedContractOverrides: (root: Command) => SharedContractOverride[];
14
+ /** The original core definitions remain available independently of each host's accepted edits. */
15
+ export declare const sharedContracts: (root: Command) => Record<string, unknown>;
16
+ /** Preserve the factory signature while recording the definition before host composition can edit it. */
17
+ export declare const sharedCommand: <Args extends unknown[], Result>(factory: (...args: Args) => Result) => (...args: Args) => Result;
18
+ /** Bind a provider shell only when its exact, reviewed differences still match Core. */
19
+ export declare const customizeSharedCommand: (reference: Command, reason: string, build: () => Command, expectedOverrides: readonly SharedContractOverride[]) => Command;
@@ -0,0 +1,207 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { Command } from "commander";
3
+ import { CliError } from "../errors.js";
4
+ import { describeProgram } from "./registry.js";
5
+ const protectedCommands = new WeakMap();
6
+ export const hasSharedContract = (command) => protectedCommands.has(command);
7
+ const snapshot = (command, children) => {
8
+ const root = new Command("contract");
9
+ const parent = command.parent;
10
+ root.addCommand(command);
11
+ const described = describeProgram(root)[0];
12
+ command.parent = parent;
13
+ if (!described)
14
+ throw new Error("a hidden command cannot own a shared contract");
15
+ const { commands: _, usage: __, path: ___, ...contract } = described;
16
+ return structuredClone({
17
+ ...contract,
18
+ aliases: [...command.aliases()],
19
+ children: (children ?? protectedCommands.get(command)?.originalChildren ?? command.commands)
20
+ .filter((child) => command.commands.includes(child) && !child._hidden)
21
+ .map((child) => child.name()),
22
+ });
23
+ };
24
+ const same = isDeepStrictEqual;
25
+ const visit = (root, fn) => {
26
+ fn(root);
27
+ for (const child of root.commands)
28
+ visit(child, fn);
29
+ };
30
+ const pathOf = (command) => {
31
+ const path = [command.name()];
32
+ for (let at = command.parent; at?.parent; at = at.parent)
33
+ path.unshift(at.name());
34
+ return path;
35
+ };
36
+ const differences = (before, after) => [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((field) => !same(before[field], after[field]));
37
+ export const assertSharedContracts = (root) => {
38
+ const changed = [];
39
+ visit(root, (command) => {
40
+ const protectedContract = protectedCommands.get(command);
41
+ if (!protectedContract)
42
+ return;
43
+ const fields = differences(protectedContract.contract, snapshot(command));
44
+ if (fields.length)
45
+ changed.push({ command: pathOf(command).join(" "), fields });
46
+ });
47
+ if (changed.length)
48
+ throw new CliError("configuration_error", `shared contract changed without an explicit override: ${changed.map((one) => `${one.command} (${one.fields.join(", ")})`).join("; ")}; use overrideSharedContract before changing it`, { reason: "contract.unacknowledged-override", contracts: changed });
49
+ };
50
+ /** The callback names the deliberate edit; an already changed contract cannot be acknowledged after the fact. */
51
+ export const overrideSharedContract = (command, reason, change) => {
52
+ if (reason.trim().length < 10)
53
+ throw new Error("a shared contract override needs a specific reason");
54
+ const protectedContract = protectedCommands.get(command);
55
+ if (!protectedContract)
56
+ throw new Error("only a core-owned command contract can be overridden");
57
+ assertSharedContracts(command);
58
+ const before = snapshot(command);
59
+ change(command);
60
+ const after = snapshot(command);
61
+ const fields = differences(before, after);
62
+ if (fields.length === 0)
63
+ throw new Error("a shared contract override must change its named command");
64
+ for (const field of fields)
65
+ protectedContract.overrides.push({
66
+ command: pathOf(command),
67
+ field,
68
+ before: before[field] ?? null,
69
+ after: after[field] ?? null,
70
+ reason: reason.trim(),
71
+ });
72
+ protectedContract.contract = after;
73
+ return command;
74
+ };
75
+ export const sharedContractOverrides = (root) => {
76
+ assertSharedContracts(root);
77
+ const result = [];
78
+ visit(root, (command) => {
79
+ for (const override of protectedCommands.get(command)?.overrides ?? [])
80
+ result.push({ ...structuredClone(override), command: pathOf(command) });
81
+ });
82
+ return result;
83
+ };
84
+ /** The original core definitions remain available independently of each host's accepted edits. */
85
+ export const sharedContracts = (root) => {
86
+ assertSharedContracts(root);
87
+ const result = {};
88
+ visit(root, (command) => {
89
+ const contract = protectedCommands.get(command);
90
+ if (contract)
91
+ result[pathOf(command).join(" ")] = structuredClone(contract.reference);
92
+ });
93
+ return result;
94
+ };
95
+ /** Preserve the factory signature while recording the definition before host composition can edit it. */
96
+ export const sharedCommand = (factory) => (...args) => {
97
+ const first = args[0];
98
+ const before = new Set();
99
+ if (first instanceof Command)
100
+ visit(first, (command) => {
101
+ before.add(command);
102
+ });
103
+ const result = factory(...args);
104
+ const roots = [];
105
+ if (result instanceof Command)
106
+ roots.push(result);
107
+ else if (Array.isArray(result))
108
+ for (const item of result)
109
+ if (item instanceof Command)
110
+ roots.push(item);
111
+ if (first instanceof Command)
112
+ visit(first, (command) => {
113
+ if (!before.has(command) && command.parent && before.has(command.parent) && !roots.includes(command))
114
+ roots.push(command);
115
+ });
116
+ for (const shared of roots) {
117
+ visit(shared, (command) => {
118
+ const first = command.description().split("\n")[0] ?? "";
119
+ if (!command.summary() && first.length > 80)
120
+ command.summary(`${first.slice(0, 77).replace(/\s+\S*$/, "")}…`);
121
+ });
122
+ visit(shared, (command) => {
123
+ if (!command._hidden && !protectedCommands.has(command))
124
+ protectedCommands.set(command, {
125
+ contract: snapshot(command),
126
+ reference: snapshot(command),
127
+ overrides: [],
128
+ originalChildren: [...command.commands],
129
+ });
130
+ });
131
+ shared.hook("preAction", () => {
132
+ let root = shared;
133
+ while (root.parent)
134
+ root = root.parent;
135
+ assertSharedContracts(root);
136
+ });
137
+ }
138
+ return result;
139
+ };
140
+ /** Bind a provider shell only when its exact, reviewed differences still match Core. */
141
+ export const customizeSharedCommand = (reference, reason, build, expectedOverrides) => {
142
+ if (reason.trim().length < 10)
143
+ throw new Error("a shared contract override needs a specific reason");
144
+ assertSharedContracts(reference);
145
+ if (!hasSharedContract(reference))
146
+ throw new Error("a provider shell needs a protected shared definition");
147
+ const command = build();
148
+ assertSharedContracts(command);
149
+ const pending = [];
150
+ const edits = [];
151
+ const bindingPath = (own) => {
152
+ const path = [own.name()];
153
+ for (let at = own.parent; at && own !== command; at = at.parent) {
154
+ path.unshift(at.name());
155
+ if (at === command)
156
+ break;
157
+ }
158
+ return path;
159
+ };
160
+ const adopt = (shared, own) => {
161
+ const inherited = protectedCommands.get(shared);
162
+ if (!inherited)
163
+ return;
164
+ if (!protectedCommands.has(own)) {
165
+ const originalChildren = own.commands.filter((child) => inherited.reference.children.includes(child.name()));
166
+ const actual = snapshot(own, originalChildren);
167
+ const overrides = differences(inherited.contract, actual).map((field) => ({
168
+ command: bindingPath(own),
169
+ field,
170
+ before: inherited.contract[field] ?? null,
171
+ after: actual[field] ?? null,
172
+ reason: reason.trim(),
173
+ }));
174
+ for (const edit of overrides)
175
+ edits.push(edit);
176
+ pending.push({
177
+ command: own,
178
+ record: {
179
+ reference: structuredClone(inherited.reference),
180
+ contract: actual,
181
+ overrides: [...structuredClone(inherited.overrides), ...overrides],
182
+ originalChildren,
183
+ },
184
+ });
185
+ }
186
+ for (const child of shared.commands) {
187
+ const actual = own.commands.find((one) => one.name() === child.name());
188
+ if (actual)
189
+ adopt(child, actual);
190
+ }
191
+ };
192
+ adopt(reference, command);
193
+ const ordered = (records) => [...records].sort((a, b) => JSON.stringify([a.command, a.field]).localeCompare(JSON.stringify([b.command, b.field])));
194
+ if (!Array.isArray(expectedOverrides) || !same(ordered(edits), ordered(expectedOverrides)))
195
+ throw new CliError("configuration_error", `provider shell ${command.name()} differs from its reviewed shared contract overrides; review Core and provider definitions before updating the explicit declarations`, { reason: "contract.unacknowledged-override", expected: expectedOverrides ?? null, actual: edits });
196
+ for (const { command: own, record } of pending) {
197
+ protectedCommands.set(own, record);
198
+ own.hook("preAction", () => {
199
+ let root = own;
200
+ while (root.parent)
201
+ root = root.parent;
202
+ assertSharedContracts(root);
203
+ });
204
+ }
205
+ assertSharedContracts(command);
206
+ return command;
207
+ };
@@ -6,7 +6,7 @@
6
6
  * a command came from, whether it changes anything, whether it is on its way out — is attached with
7
7
  * `annotate`, and to an option with `annotateOption`.
8
8
  *
9
- * Behind its own entry point for the same reason `/http` is: Commander is only a type here, and the
9
+ * Behind its own entry point for the same reason `/http` is: Commander is loaded by contract guards here, and the
10
10
  * root of this package stays free of any command-line framework.
11
11
  */
12
12
  import type { Command } from "commander";
@@ -20,3 +20,4 @@ export * from "./registry.js";
20
20
  * `prefix` keeps those caller-supplied words between the tool name and path in the hint.
21
21
  */
22
22
  export declare const refuseJoinedPath: (root: Command, args: readonly string[], prefix?: readonly string[]) => void;
23
+ export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedCommand, sharedContractOverrides, sharedContracts, } from "./contracts.js";
@@ -24,3 +24,4 @@ export const refuseJoinedPath = (root, args, prefix = []) => {
24
24
  command = next;
25
25
  }
26
26
  };
27
+ export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedCommand, sharedContractOverrides, sharedContracts, } from "./contracts.js";
@@ -3,12 +3,16 @@ import { type AuditRule, type ParityGroup, type RequiredRoot } from "../standard
3
3
  import { type ErrorReason } from "../standards/errors.js";
4
4
  import { type Override } from "../standards/lint.js";
5
5
  import type { Vocabulary } from "../standards/vocabulary.js";
6
+ import { type Capabilities } from "./capabilities.js";
7
+ import { type ContractEdit } from "./shared-contracts.js";
6
8
  export interface AuditInput {
7
9
  document: CommandsDocument;
8
10
  /** As the CLI printed it, for `key-casing`. */
9
11
  raw: unknown;
10
12
  schemas?: Readonly<Record<string, unknown>>;
11
13
  reasons?: readonly ErrorReason[];
14
+ contracts?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
15
+ overrides?: readonly ContractEdit[];
12
16
  /** The CLI's own `lint/` baseline and overrides; its lint rules are then judged by them, not the audit's. */
13
17
  lint?: {
14
18
  baseline: Readonly<Record<string, readonly string[]>>;
@@ -41,6 +45,7 @@ export interface AuditOptions {
41
45
  vocabulary: Vocabulary;
42
46
  /** `audit/baseline/<rule>.json`, by rule. */
43
47
  baseline: Readonly<Record<string, readonly string[]>>;
48
+ capabilities?: Capabilities;
44
49
  blocks: "new" | "never";
45
50
  /** Only new findings that name this CLI block: the one being released. */
46
51
  only?: string;
package/dist/dev/audit.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { AUDIT_RULES } from "../standards/audit.js";
2
2
  import { BASE_REASONS } from "../standards/errors.js";
3
3
  import { LINT_RULES, OVERRIDE_BUDGET } from "../standards/lint.js";
4
+ import { capabilityDifference } from "./capabilities.js";
4
5
  import { judge, kindOf, lintCommands, longName, summaryOf, walk } from "./lint.js";
6
+ import { sharedContractProblems } from "./shared-contracts.js";
7
+ const STRICT_CONTRACT_RULES = new Set([
8
+ "shared-command-contract",
9
+ "same-path-contract",
10
+ "same-flag-meaning",
11
+ "same-entity-schema",
12
+ "same-error-reason",
13
+ "required-root",
14
+ ]);
5
15
  const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
6
16
  const toSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
7
17
  const VALUE_KEYS = new Set(["default", "implies"]);
@@ -66,8 +76,16 @@ const samePathContract = (inputs, groups) => {
66
76
  message: describe(values),
67
77
  });
68
78
  };
69
- const kinds = new Map([...commands].map(([cli, command]) => [cli, optionKinds(command.options)]));
70
- const flags = [...new Set([...kinds.values()].flatMap((one) => [...one.keys()]))].sort(byText);
79
+ const kinds = new Map([...commands].map(([cli, command]) => [
80
+ cli,
81
+ optionKinds([
82
+ ...(inputs.find((input) => input.document.cli === cli)?.document.global_options ?? []),
83
+ ...command.options,
84
+ ]),
85
+ ]));
86
+ const flags = [
87
+ ...new Set([...commands.values()].flatMap((command) => [...optionKinds(command.options).keys()])),
88
+ ].sort(byText);
71
89
  for (const flag of flags)
72
90
  push(` ${flag}`, new Map([...kinds].map(([cli, one]) => [cli, shownKind(one.get(flag))])));
73
91
  push(": output", new Map([...commands].map(([cli, command]) => [
@@ -166,6 +184,14 @@ const sameErrorReason = (inputs) => {
166
184
  return found;
167
185
  };
168
186
  const CROSS = {
187
+ "shared-command-contract": (inputs) => inputs.flatMap((input) => input.contracts
188
+ ? sharedContractProblems(snakeKeys(input.raw).commands, input.contracts, input.overrides).map((problem) => ({
189
+ rule: "shared-command-contract",
190
+ clis: [input.document.cli],
191
+ target: `${input.document.cli} ${problem.path}`,
192
+ message: problem.message,
193
+ }))
194
+ : []),
169
195
  "required-root": (inputs, options) => requiredRoots(inputs, options.roots),
170
196
  "same-path-contract": (inputs, options) => samePathContract(inputs, options.groups),
171
197
  "same-flag-meaning": (inputs) => sameFlagMeaning(inputs),
@@ -173,6 +199,7 @@ const CROSS = {
173
199
  "same-error-reason": (inputs) => sameErrorReason(inputs),
174
200
  };
175
201
  const measuredBy = {
202
+ "shared-command-contract": (input) => input.contracts !== undefined,
176
203
  "same-entity-schema": (input) => input.schemas !== undefined,
177
204
  "same-error-reason": (input) => input.reasons !== undefined,
178
205
  };
@@ -208,15 +235,44 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
208
235
  return check(inputs, options);
209
236
  }),
210
237
  ];
238
+ const protectedInputs = new Set(inputs
239
+ .filter((input) => input.contracts &&
240
+ sharedContractProblems(snakeKeys(input.raw).commands, input.contracts, input.overrides)
241
+ .length === 0)
242
+ .map((input) => input.document.cli));
243
+ const inheritedAccepted = new Set(findings
244
+ .filter((finding) => {
245
+ if (finding.rule !== "same-path-contract" || finding.target.endsWith(": presence"))
246
+ return false;
247
+ const path = finding.target.slice(finding.target.indexOf(" ") + 1).split(/ --|: /)[0] ?? "";
248
+ return finding.clis.every((cli) => protectedInputs.has(cli) && inputs.find((input) => input.document.cli === cli)?.contracts?.[path]);
249
+ })
250
+ .map((finding) => `${finding.rule} ${finding.target}`));
251
+ const capabilityAccepted = new Set(findings
252
+ .filter((finding) => finding.rule === "same-path-contract" &&
253
+ options.capabilities &&
254
+ capabilityDifference(finding.target, finding.clis, inputs.map((input) => input.document), options.capabilities))
255
+ .map((finding) => `${finding.rule} ${finding.target}`));
211
256
  const declared = new Map(options.groups.flatMap((group) => group.differences.map((entry) => [`${entry.rule} ${entry.target}`, group.name])));
212
257
  const found = new Set(findings.map((finding) => `${finding.rule} ${finding.target}`));
213
258
  for (const [key, group] of declared)
214
259
  if (!found.has(key))
215
260
  problems.push(`stale difference in ${group}, no such divergence — remove it: ${key}`);
261
+ for (const group of options.groups) {
262
+ for (const difference of group.differences) {
263
+ const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
264
+ if (finding && difference.expected !== finding.message) {
265
+ declared.delete(`${difference.rule} ${difference.target}`);
266
+ problems.push(`difference in ${group.name} must match the exact current values: ${difference.rule} ${difference.target}`);
267
+ }
268
+ }
269
+ }
216
270
  for (const group of options.groups)
217
271
  if (group.differences.length > OVERRIDE_BUDGET)
218
272
  problems.push(`${group.name} declares ${group.differences.length} differences; the budget is ${OVERRIDE_BUDGET}`);
219
- const baselined = new Set(Object.entries(options.baseline).flatMap(([rule, targets]) => targets.map((target) => `${rule} ${target}`)));
273
+ const baselined = new Set(Object.entries(options.baseline)
274
+ .filter(([rule]) => !STRICT_CONTRACT_RULES.has(rule))
275
+ .flatMap(([rule, targets]) => targets.map((target) => `${rule} ${target}`)));
220
276
  // A cross-CLI entry names every CLI it compared; a run without one of them can neither see it nor fix it.
221
277
  const judged = (target) => clisOf(target).every((cli) => clis.includes(cli));
222
278
  const ownLint = (cli, rule) => lintIds.includes(rule) ? inputs.find((input) => input.document.cli === cli)?.lint : undefined;
@@ -245,7 +301,9 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
245
301
  blocking.push(...mine.filter((finding) => row.new_targets.includes(finding.target)));
246
302
  }
247
303
  else {
248
- const open = mine.filter((finding) => !declared.has(`${rule} ${finding.target}`));
304
+ const open = mine.filter((finding) => !declared.has(`${rule} ${finding.target}`) &&
305
+ !capabilityAccepted.has(`${rule} ${finding.target}`) &&
306
+ !inheritedAccepted.has(`${rule} ${finding.target}`));
249
307
  const fresh = open.filter((finding) => !baselined.has(`${rule} ${finding.target}`));
250
308
  const here = new Set(mine.map((finding) => finding.target));
251
309
  row = {
@@ -272,10 +330,13 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
272
330
  const nextBaseline = Object.fromEntries([...lintIds, ...rules.map((rule) => rule.id)].map((rule) => {
273
331
  const kept = (options.baseline[rule] ?? []).filter((target) => !judged(target));
274
332
  const now = findings
275
- .filter((finding) => finding.rule === rule && !declared.has(`${rule} ${finding.target}`))
333
+ .filter((finding) => finding.rule === rule &&
334
+ !declared.has(`${rule} ${finding.target}`) &&
335
+ !capabilityAccepted.has(`${rule} ${finding.target}`) &&
336
+ !inheritedAccepted.has(`${rule} ${finding.target}`))
276
337
  .filter((finding) => !(lintIds.includes(rule) && selfJudged.has(finding.clis[0] ?? "")))
277
338
  .map((finding) => finding.target);
278
- return [rule, [...new Set([...kept, ...now])].sort(byText)];
339
+ return [rule, STRICT_CONTRACT_RULES.has(rule) ? kept : [...new Set([...kept, ...now])].sort(byText)];
279
340
  }));
280
341
  const unique = new Map(blocking.map((finding) => [`${finding.rule} ${finding.target}`, finding]));
281
342
  return {
@@ -0,0 +1,20 @@
1
+ import * as v from "valibot";
2
+ import type { CommandsDocument } from "../commands/index.js";
3
+ declare const manifestSchema: v.ObjectSchema<{
4
+ readonly clis: v.ArraySchema<v.StringSchema<undefined>, undefined>;
5
+ readonly commands: v.RecordSchema<v.StringSchema<undefined>, v.ObjectSchema<{
6
+ readonly in: v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>;
7
+ readonly reason: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
8
+ readonly planned: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
9
+ readonly options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ObjectSchema<{
10
+ readonly in: v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>;
11
+ readonly reason: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
12
+ readonly planned: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
13
+ }, undefined>], undefined>, undefined>, undefined>;
14
+ }, undefined>, undefined>;
15
+ }, undefined>;
16
+ export type Capabilities = v.InferOutput<typeof manifestSchema>;
17
+ export declare const readCapabilities: (input: unknown) => Capabilities;
18
+ /** Presence differences are accepted only when every observed value matches a declared provider capability. */
19
+ export declare const capabilityDifference: (target: string, clis: readonly string[], documents: readonly CommandsDocument[], manifest: Capabilities) => boolean;
20
+ export {};
@@ -0,0 +1,48 @@
1
+ import * as v from "valibot";
2
+ import { kindOf, longName, walk } from "./lint.js";
3
+ const availability = v.union([v.literal("all"), v.array(v.string())]);
4
+ const entry = v.object({
5
+ in: availability,
6
+ reason: v.optional(v.string()),
7
+ planned: v.optional(v.record(v.string(), v.string())),
8
+ });
9
+ const option = v.union([v.literal("all"), entry]);
10
+ const manifestSchema = v.object({
11
+ clis: v.array(v.string()),
12
+ commands: v.record(v.string(), v.object({ ...entry.entries, options: v.optional(v.record(v.string(), option)) })),
13
+ });
14
+ export const readCapabilities = (input) => v.parse(manifestSchema, input);
15
+ /** Presence differences are accepted only when every observed value matches a declared provider capability. */
16
+ export const capabilityDifference = (target, clis, documents, manifest) => {
17
+ if (clis.some((cli) => !manifest.clis.includes(cli)))
18
+ return false;
19
+ const words = target.slice(target.indexOf(" ") + 1);
20
+ if (words.endsWith(": presence")) {
21
+ const path = words.slice(0, -10);
22
+ const ancestor = path
23
+ .split(" ")
24
+ .map((_, index, parts) => parts.slice(0, parts.length - index).join(" "))
25
+ .find((name) => manifest.commands[name] !== undefined);
26
+ const policy = ancestor ? manifest.commands[ancestor] : undefined;
27
+ if (!policy || policy.in === "all" || (!policy.reason && !policy.planned))
28
+ return false;
29
+ return clis.every((cli) => walk(documents.find((doc) => doc.cli === cli)?.commands ?? []).some((command) => command.path.join(" ") === path) === policy.in.includes(cli));
30
+ }
31
+ const flagAt = words.lastIndexOf(" --");
32
+ if (flagAt < 0)
33
+ return false;
34
+ const path = words.slice(0, flagAt), flag = words.slice(flagAt + 1);
35
+ const policy = manifest.commands[path]?.options?.[flag];
36
+ if (!policy || policy === "all" || policy.in === "all" || (!policy.reason && !policy.planned))
37
+ return false;
38
+ const signatures = new Set();
39
+ const matches = clis.every((cli) => {
40
+ const doc = documents.find((doc) => doc.cli === cli);
41
+ const command = walk(doc?.commands ?? []).find((command) => command.path.join(" ") === path);
42
+ const option = [...(doc?.global_options ?? []), ...(command?.options ?? [])].find((option) => longName(option.flags) === flag);
43
+ if (option)
44
+ signatures.add(kindOf(option));
45
+ return (option !== undefined) === policy.in.includes(cli);
46
+ });
47
+ return matches && signatures.size <= 1;
48
+ };
package/dist/dev/lint.js CHANGED
@@ -173,14 +173,16 @@ const filteredBy = (flag) => {
173
173
  return { window: "id" };
174
174
  return { field: bare.replace(/-(time|id|n)$/, "").replaceAll("-", "_") };
175
175
  };
176
- /** An entity's fields: the query's, then its registered schema's properties; `time` for a date-time. */
176
+ /** A declared query is authoritative; without one, scalar schema fields reveal legacy list filters. */
177
177
  const entityFields = (command, context) => {
178
178
  const fields = new Map((command.query?.fields ?? []).map((field) => [field.name, field.type]));
179
- const entity = command.output?.entity ?? command.query?.entity;
179
+ if (command.query)
180
+ return fields;
181
+ const entity = command.output?.entity;
180
182
  const schema = entity === undefined ? undefined : context.schemas?.[entity];
181
183
  const properties = isObject(schema) && isObject(schema.properties) ? schema.properties : {};
182
184
  for (const [name, property] of Object.entries(properties))
183
- if (!fields.has(name))
185
+ if (!fields.has(name) && !typesOf(property).includes("array"))
184
186
  fields.set(name, isObject(property) && property.format === "date-time" ? "time" : "other");
185
187
  return fields;
186
188
  };
@@ -299,11 +301,22 @@ const CHECKS = {
299
301
  "filter-flag": (document, vocabulary, context) => {
300
302
  const roles = new Map(vocabulary.flags.map((flag) => [flag.name, flag.role]));
301
303
  return walk(document.commands)
302
- .filter((command) => command.origin === "handwritten" && !declaredWrite(command) && (command.query || command.output))
304
+ .filter((command) => command.origin === "handwritten" &&
305
+ !declaredWrite(command) &&
306
+ (command.query || (["list", "events", "runs"].includes(command.name) && command.output)))
303
307
  .flatMap((command) => {
304
308
  const fields = entityFields(command, context);
305
309
  return command.options
306
- .filter((option) => roles.get(longName(option.flags)) !== "scope")
310
+ .filter((option) => {
311
+ const flag = longName(option.flags), role = roles.get(flag);
312
+ if (role === "scope")
313
+ return false;
314
+ const { field, window } = filteredBy(flag);
315
+ const queryField = command.query?.fields.some((one) => one.name === field);
316
+ return (window !== undefined ||
317
+ queryField ||
318
+ !["paging", "output", "sort", "mode", "budget", "action"].includes(role ?? ""));
319
+ })
307
320
  .flatMap((option) => {
308
321
  const { field, window } = filteredBy(longName(option.flags));
309
322
  const hit = window === "time"
package/dist/dev/main.js CHANGED
@@ -11,12 +11,13 @@ import spawn from "cross-spawn";
11
11
  import * as v from "valibot";
12
12
  import { parseCommandsDocument } from "../commands/document.js";
13
13
  import { changelogProblems, docsProblems, JOURNAL_IDS, markdownFiles, packageVersion, structureProblems, versionScript, } from "../release/index.js";
14
- import { AUDIT_BLOCKS, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
14
+ import { AUDIT_BLOCKS, defineParityGroups, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
15
15
  import { defineReasons } from "../standards/errors.js";
16
16
  import { baselineProblems, baselineSchema, LINT_RULES, OVERRIDE_BUDGET, OVERRIDE_RULE_REVIEW, overridesProblems, overridesSchema, rulesForReview, } from "../standards/lint.js";
17
17
  import { issuesOf } from "../standards/registry.js";
18
18
  import { VOCABULARY } from "../standards/vocabulary.js";
19
19
  import { auditClis, scorecardTable, snakeKeys } from "./audit.js";
20
+ import { readCapabilities } from "./capabilities.js";
20
21
  import { changelogChange, landFold, processShell } from "./land.js";
21
22
  import { baselineOf, judge, lintCommands } from "./lint.js";
22
23
  import { nextVersion } from "./next-version.js";
@@ -48,7 +49,7 @@ const USAGE = `usage: cli-dev <command> [options]
48
49
  lint/baseline/<rule>.json to today's violations. --schemas
49
50
  is a JSON object of the CLI's own JSON Schemas by name
50
51
  audit (--cli <bin> | --commands <file>)... [--schemas <cli>=<file>]... [--reasons <cli>=<file>]...
51
- [--lint <cli>=<dir>]... [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
52
+ [--lint <cli>=<dir>]... [--capabilities <file>] [--complete] [--differences <file>] [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
52
53
  several CLIs against every lint rule and against each other:
53
54
  required roots, same path same contract, same flag same
54
55
  meaning, same entity same schema, same failure same reason.
@@ -186,12 +187,12 @@ const readJson = (path) => {
186
187
  throw new Error(`${path}: ${error.message}`);
187
188
  }
188
189
  };
189
- const commandsJson = (io, cli, file) => {
190
+ const commandsJson = (io, cli, file, commandArgs = ["commands", "--json"]) => {
190
191
  if (file)
191
192
  return readJson(isAbsolute(file) ? file : join(io.cwd, file));
192
193
  const bin = cli ?? "";
193
194
  const script = /\.[cm]?js$/.test(bin);
194
- const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), "commands", "--json"], {
195
+ const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), ...commandArgs], {
195
196
  cwd: io.cwd,
196
197
  encoding: "utf8",
197
198
  maxBuffer: 64 * 1024 * 1024,
@@ -331,6 +332,9 @@ const auditRun = (args, io) => {
331
332
  cli: { type: "string", multiple: true },
332
333
  commands: { type: "string", multiple: true },
333
334
  schemas: { type: "string", multiple: true },
335
+ capabilities: { type: "string" },
336
+ complete: { type: "boolean", default: false },
337
+ differences: { type: "string" },
334
338
  reasons: { type: "string", multiple: true },
335
339
  lint: { type: "string", multiple: true },
336
340
  baseline: { type: "string", default: "audit/baseline" },
@@ -352,18 +356,60 @@ const auditRun = (args, io) => {
352
356
  const reasons = byCli(values.reasons, "--reasons");
353
357
  const lintDirs = byCli(values.lint, "--lint");
354
358
  const problems = [];
359
+ const evidence = new Map();
360
+ if (values.complete) {
361
+ for (const [index, bin] of (values.cli ?? []).entries()) {
362
+ const raw = raws[index];
363
+ const data = commandsJson(io, bin, undefined, ["commands", "info", "--json"]);
364
+ if (typeof raw.cli === "string")
365
+ evidence.set(raw.cli, data);
366
+ }
367
+ }
355
368
  const inputs = raws.map((raw) => {
356
369
  const parsed = parseCommandsDocument(snakeKeys(raw));
357
370
  if (!parsed.document)
358
371
  throw new Error(`a commands document is not the shape cli-base's commandsDocument() writes: ${parsed.problems[0]}`);
359
372
  const cli = parsed.document.cli;
360
- const input = { document: parsed.document, raw };
373
+ const input = { document: parsed.document, raw, ...evidence.get(cli) };
361
374
  const schemaFile = schemas.get(cli);
362
375
  if (schemaFile)
363
376
  input.schemas = readJson(at(schemaFile));
364
377
  const reasonFile = reasons.get(cli);
365
378
  if (reasonFile)
366
379
  input.reasons = defineReasons(readJson(at(reasonFile)));
380
+ if (values.complete) {
381
+ if (!input.contracts ||
382
+ typeof input.contracts !== "object" ||
383
+ Array.isArray(input.contracts) ||
384
+ Object.keys(input.contracts).length === 0)
385
+ problems.push(`${cli}: complete audit needs core contract references from commands info`);
386
+ for (const root of REQUIRED_ROOTS) {
387
+ const path = root.path.join(" ");
388
+ if (!input.contracts?.[path])
389
+ problems.push(`${cli}: complete audit needs an inherited contract reference for ${path}`);
390
+ }
391
+ if (!input.schemas ||
392
+ typeof input.schemas !== "object" ||
393
+ Array.isArray(input.schemas) ||
394
+ Object.keys(input.schemas).length === 0)
395
+ problems.push(`${cli}: complete audit needs nonempty entity schemas from commands info or --schemas`);
396
+ if (!Array.isArray(input.reasons) || input.reasons.length === 0)
397
+ problems.push(`${cli}: complete audit needs declared error reasons from commands info or --reasons`);
398
+ if (input.reasons)
399
+ input.reasons = defineReasons(input.reasons);
400
+ if (input.schemas) {
401
+ const visit = (commands) => {
402
+ for (const command of commands) {
403
+ const names = [command.output?.entity, command.query?.entity].filter((name) => name !== undefined);
404
+ for (const name of names)
405
+ if (input.schemas?.[name] === undefined)
406
+ problems.push(`${cli}: complete audit has no schema for ${name} (${command.path.join(" ")})`);
407
+ visit(command.commands);
408
+ }
409
+ };
410
+ visit(input.document.commands);
411
+ }
412
+ }
367
413
  const dir = lintDirs.get(cli);
368
414
  if (dir) {
369
415
  const overridesPath = join(at(dir), "overrides.json");
@@ -398,8 +444,32 @@ const auditRun = (args, io) => {
398
444
  problems.push(`--for names ${values.for}, which is not audited`);
399
445
  const baselineDir = at(values.baseline);
400
446
  const baseline = readBaseline(baselineDir, values.baseline, problems);
447
+ let groups = PARITY_GROUPS;
448
+ if (values.differences) {
449
+ const schema = v.array(v.strictObject({
450
+ group: v.string(),
451
+ rule: v.string(),
452
+ target: v.string(),
453
+ expected: v.string(),
454
+ reason: v.string(),
455
+ }));
456
+ const parsed = v.safeParse(schema, readJson(at(values.differences)));
457
+ if (!parsed.success)
458
+ throw new Error(`${values.differences}: ${issuesOf(parsed.issues).join("; ")}`);
459
+ for (const entry of parsed.output)
460
+ if (!groups.some((group) => group.name === entry.group))
461
+ throw new Error(`${values.differences}: unknown group ${entry.group}`);
462
+ groups = defineParityGroups(groups.map((group) => ({
463
+ ...group,
464
+ differences: [
465
+ ...group.differences,
466
+ ...parsed.output.filter((entry) => entry.group === group.name).map(({ group: _, ...entry }) => entry),
467
+ ],
468
+ })));
469
+ }
401
470
  const result = auditClis(inputs, {
402
- groups: PARITY_GROUPS,
471
+ ...(values.capabilities ? { capabilities: readCapabilities(readJson(at(values.capabilities))) } : {}),
472
+ groups,
403
473
  roots: REQUIRED_ROOTS,
404
474
  vocabulary: VOCABULARY,
405
475
  baseline,
@@ -0,0 +1,13 @@
1
+ import type { CommandInfo } from "../commands/index.js";
2
+ export interface ContractEdit {
3
+ command: readonly string[];
4
+ field: string;
5
+ before: unknown;
6
+ after: unknown;
7
+ reason: string;
8
+ }
9
+ /** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
10
+ export declare const sharedContractProblems: (commands: readonly CommandInfo[], references: Readonly<Record<string, Readonly<Record<string, unknown>>>>, edits?: readonly ContractEdit[]) => {
11
+ path: string;
12
+ message: string;
13
+ }[];
@@ -0,0 +1,85 @@
1
+ const stable = (value) => {
2
+ if (Array.isArray(value))
3
+ return value.map(stable);
4
+ if (value && typeof value === "object")
5
+ return Object.fromEntries(Object.entries(value)
6
+ .sort(([a], [b]) => a.localeCompare(b))
7
+ .map(([key, child]) => [key, stable(child)]));
8
+ return value;
9
+ };
10
+ const same = (left, right) => JSON.stringify(stable(left ?? null)) === JSON.stringify(stable(right ?? null));
11
+ const FIELDS = new Set([
12
+ "name",
13
+ "summary",
14
+ "description",
15
+ "origin",
16
+ "api_operation",
17
+ "effects",
18
+ "state",
19
+ "partial",
20
+ "lifetime",
21
+ "reaches",
22
+ "mcp",
23
+ "offline",
24
+ "cooperative",
25
+ "examples",
26
+ "output",
27
+ "query",
28
+ "runnable",
29
+ "arguments",
30
+ "options",
31
+ "aliases",
32
+ "children",
33
+ ]);
34
+ /** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
35
+ export const sharedContractProblems = (commands, references, edits = []) => {
36
+ const effective = new Map(Object.entries(references).map(([path, contract]) => [path, structuredClone(contract)]));
37
+ const problems = [];
38
+ for (const edit of edits) {
39
+ const path = edit.command.join(" ");
40
+ const contract = effective.get(path);
41
+ if (!contract ||
42
+ !FIELDS.has(edit.field) ||
43
+ edit.reason.trim().length < 10 ||
44
+ !same(contract[edit.field], edit.before) ||
45
+ same(edit.before, edit.after)) {
46
+ problems.push({ path, message: `invalid or stale explicit override for ${edit.field}` });
47
+ continue;
48
+ }
49
+ if (edit.after === null)
50
+ delete contract[edit.field];
51
+ else
52
+ contract[edit.field] = structuredClone(edit.after);
53
+ }
54
+ const walk = (items, inherited) => {
55
+ for (const command of items) {
56
+ const path = command.path.join(" ");
57
+ const expected = effective.get(path);
58
+ if (expected) {
59
+ const { path: _, usage: __, commands: nested, ...fields } = command;
60
+ const original = references[path]?.children;
61
+ const actual = {
62
+ ...fields,
63
+ ...(Array.isArray(original)
64
+ ? { children: nested.filter((child) => original.includes(child.name)).map((child) => child.name) }
65
+ : {}),
66
+ };
67
+ const wanted = { ...expected, ...inherited };
68
+ const changed = [...new Set([...Object.keys(actual), ...Object.keys(wanted)])].filter((field) => field !== "aliases" &&
69
+ field !== "availability" &&
70
+ !same(actual[field], wanted[field]));
71
+ if (changed.length)
72
+ problems.push({ path, message: `core contract differs without an exact override: ${changed.join(", ")}` });
73
+ effective.delete(path);
74
+ }
75
+ walk(command.commands, {
76
+ ...(inherited.mcp === false || expected?.mcp === false ? { mcp: false } : {}),
77
+ ...(inherited.offline === false || expected?.offline === false ? { offline: false } : {}),
78
+ });
79
+ }
80
+ };
81
+ walk(commands, {});
82
+ for (const path of effective.keys())
83
+ problems.push({ path, message: "core contract names a command absent from discovery" });
84
+ return problems;
85
+ };
@@ -23,6 +23,7 @@ declare const parityGroupSchema: v.StrictObjectSchema<{
23
23
  readonly differences: v.ArraySchema<v.StrictObjectSchema<{
24
24
  readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
25
25
  readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
26
+ readonly expected: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
26
27
  readonly reason: v.SchemaWithPipe<readonly [v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, v.MinLengthAction<string, 10, "say why, in a sentence">]>;
27
28
  }, undefined>, undefined>;
28
29
  }, undefined>;
@@ -10,6 +10,11 @@ const auditRuleSchema = v.strictObject({ id: word, severity: v.picklist(LINT_SEV
10
10
  export const defineAuditRules = (input) => parseRegistry("audit rules", v.array(auditRuleSchema), input, (rules) => duplicates(rules.map((rule) => rule.id)).map((id) => `"${id}" is listed twice`));
11
11
  /** The rules that compare CLIs; each CLI is also judged alone by every lint rule. */
12
12
  export const AUDIT_RULES = defineAuditRules([
13
+ {
14
+ id: "shared-command-contract",
15
+ severity: "high",
16
+ summary: "core-owned command definitions change only through exact, reasoned overrides",
17
+ },
13
18
  {
14
19
  id: "required-root",
15
20
  severity: "high",
@@ -44,7 +49,12 @@ const parityGroupSchema = v.strictObject({
44
49
  /** `tree` compares every path; otherwise the root words whose paths are compared. */
45
50
  covers: v.union([v.literal("tree"), v.pipe(v.array(segment), v.minLength(1))]),
46
51
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
47
- differences: v.array(v.strictObject({ rule: word, target: text, reason: v.pipe(text, v.minLength(10, "say why, in a sentence")) })),
52
+ differences: v.array(v.strictObject({
53
+ rule: word,
54
+ target: text,
55
+ expected: v.optional(text),
56
+ reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
57
+ })),
48
58
  });
49
59
  export const defineParityGroups = (input) => parseRegistry("parity groups", v.array(parityGroupSchema), input, (groups) => [
50
60
  ...duplicates(groups.map((group) => group.name)).map((name) => `"${name}" is listed twice`),
@@ -291,6 +291,12 @@ export const HOW_FLAGS = [
291
291
  type: "duration",
292
292
  meaning: "how old a stored snapshot may be and still count as fresh",
293
293
  },
294
+ {
295
+ name: "--draft",
296
+ role: "mode",
297
+ type: "switch",
298
+ meaning: "save text for the owner to review and send, without sending a message",
299
+ },
294
300
  { name: "--md", role: "mode", type: "switch", meaning: "the text is the messenger's Markdown" },
295
301
  { name: "--no-backup", role: "mode", type: "switch", meaning: "do not back the file up before deleting it" },
296
302
  { name: "--no-input", role: "mode", type: "switch", meaning: "never prompt or open an interactive login" },
@@ -448,6 +454,12 @@ export const HOW_FLAGS = [
448
454
  meaning: "override one permission for this run; repeat it for more",
449
455
  },
450
456
  { name: "--read-only", role: "mode", type: "switch", meaning: "refuse every write, local and remote" },
457
+ {
458
+ name: "--append",
459
+ role: "mode",
460
+ type: "switch",
461
+ meaning: "keep existing entries and add the explicitly named one beside them",
462
+ },
451
463
  { name: "--replace", role: "mode", type: "switch", meaning: "overwrite an existing one of the same name" },
452
464
  {
453
465
  name: "--rescan",
@@ -436,6 +436,13 @@ export const WHAT_FLAGS = [
436
436
  },
437
437
  { name: "--task", value: "<id>", role: "target", type: "id", meaning: "a task, by id" },
438
438
  { name: "--to", value: "<chat>", role: "target", type: "id", meaning: "the chat it goes to" },
439
+ {
440
+ name: "--topic-name",
441
+ value: "<name>",
442
+ role: "target",
443
+ type: "string",
444
+ meaning: "a stored meeting topic label, by name or alias",
445
+ },
439
446
  { name: "--topic", value: "<id>", role: "target", type: "id", meaning: "the forum topic, in a chat that has topics" },
440
447
  { name: "--transcript-id", value: "<id>", role: "target", type: "id", meaning: "one transcript version, by id" },
441
448
  { name: "--until-id", value: "<id>", role: "target", type: "id", meaning: "up to and including this message id" },
@@ -38,8 +38,8 @@ rules it keeps. A new CLI is ready for its first release when every check under
38
38
  | [Testing](testing.md) | `TEST` | 17 | Sandbox, no network, no real waits, leak tests, coverage, contracts, test matrix, parity |
39
39
  | [Security](security.md) | `SECR` | 20 | Secrets, logs, keyring, argv, error text, untrusted text, scanners, workflow hardening, reporting |
40
40
  | [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
41
- | [Comparisons](comparisons.md) | `CMP` | 10 | Which tools a comparison page lists, rows as user tasks, agents and safety, where others are better, proof of marks |
42
- | [Releases](releases.md) | `REL` | 16 | `bin/release`, the release lock, versions, release reports, trusted publishing |
41
+ | [Comparisons](comparisons.md) | `CMP` | 11 | Which tools a comparison page lists, rows as user tasks, agents and safety, where others are better, proof of marks |
42
+ | [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, release reports, trusted publishing |
43
43
  | [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
44
44
  | [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 |
45
45
  | [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
@@ -2,9 +2,13 @@
2
2
 
3
3
  ## Scope
4
4
 
5
- How a CLI's comparison page (`docs/compare.md`) is written: which tools it compares, which rows it has, how a mark is
6
- proven, and what it says about the places where another tool is better. The page is read by someone choosing a tool
7
- for a job, not by someone reviewing our code.
5
+ How a CLI's comparison page is written: which tools it compares, which rows it has, how a mark is proven, and what it
6
+ says about the places where another tool is better. The page is read by someone choosing a tool for a job, not by
7
+ someone reviewing our code.
8
+
9
+ The page lives in the site repository (`cli-docs`, published on wirecat.dev), not in the CLI repository, because a
10
+ comparison changes when the other tools change, not when we release. The CLI repository keeps a short
11
+ `docs/compare.md` that links to the site page, and its other docs link there too.
8
12
 
9
13
  Rule ids on this page are `WC-CMP-<n>`. How to read a rule, how ids work and which rule wins are in the
10
14
  [index](README.md). The general rules for user pages are in [Documents](docs.md).
@@ -112,7 +116,18 @@ Rule ids on this page are `WC-CMP-<n>`. How to read a rule, how ids work and whi
112
116
  - **Exceptions:** none.
113
117
  - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
114
118
 
119
+ ### WC-CMP-11 · Our marks are re-checked at each release
120
+
121
+ - **Why:** our own marks go stale with every release, and a mark that says we lack something we have shipped, or the
122
+ reverse, is the error a reader finds first.
123
+ - **Enforced by:** review only.
124
+ - **Example:** the release report lists the compare page as checked. Before a release, each ✅, 🟡 and ❌ for the CLI is re-proven against the release build, the check date
125
+ and version in the intro are updated in the site repository, and the release report names the page.
126
+ - **Exceptions:** none.
127
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.5. **Rulings:** owner, 2026-10-24.
128
+
115
129
  ## Sources
116
130
 
117
131
  - Owner review of the max, zm and memo comparison pages, 2026-10-24.
118
- - The tg comparison page, `tg-cli/docs/compare.md`, the first page written this way.
132
+ - The tg comparison page, the first page written this way.
133
+ - Owner decision, 2026-10-24: comparison pages live in `cli-docs`; each CLI's docs link to them.
@@ -92,8 +92,8 @@ whatsapp-cli is the worked example: it has every file below. Compare with it whe
92
92
  8. **`SECURITY.md`**: how to report a vulnerability, linking cli-testing's security method. See
93
93
  [Security](security.md).
94
94
  9. **`bin/release`**, **`release.yml`** and a release skill, copied from the nearest CLI. `bin/release` takes the
95
- release lock (WC-REL-3) and npm's trusted publisher settings name `release.yml`. A messenger CLI also waits two
96
- hours between releases and checks a signed release report (WC-REL-8, WC-REL-10). See [Releases](releases.md).
95
+ release lock (WC-REL-3) and npm's trusted publisher settings name `release.yml`. A messenger CLI also checks a signed
96
+ release report (WC-REL-10). See [Releases](releases.md).
97
97
  10. **`src/version.ts`** kept in step with `package.json`. Proved by: `pnpm version:check`.
98
98
 
99
99
  ## Done when
@@ -88,29 +88,17 @@ on the [CI](ci.md) page.
88
88
  - **Exceptions:** a pre-release version (`0.41.0-rc.1`) is chosen by hand.
89
89
  - **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.41.0. **Rulings:** none.
90
90
 
91
- ### WC-REL-8 · A messenger CLI waits two hours between releases; a shared library does not wait
91
+ ### WC-REL-8 · Any package may release at any time
92
92
 
93
- - **Why:** a broken CLI release must be noticed before the next one lands on top of it. Every CLI waits for a library
94
- release, so a gap there only delays them.
95
- - **Enforced by:** `<app>: bin/release`.
96
- - **Example:** tg-cli, max-cli and whatsapp-cli's `bin/release` refuse to start within two hours of the last version
97
- on npm. cli-base and cli-core release one after another with no gap.
98
- - **Exceptions:** memo, drive and zoom have no gap in their `bin/release` today.
99
- - **Applies to:** messenger CLIs (gap); shared libraries (no gap). **Since:** cli-base 0.41.0. **Rulings:** NEED-854.
100
-
101
- ### WC-REL-9 · A change to a stable shared-library export that makes a CLI change its code ships at most once a week
102
-
103
- - **Why:** the limit keeps the CLIs from chasing versions; a change that no CLI has to follow gives them nothing to
104
- chase.
105
- - **Enforced by:** `cli-core: parity.yml` (it decides which changes count; review keeps the week).
106
- - **Example:** cli-core's parity jobs build tg-cli, max-cli and whatsapp-cli from their `main` against every pull
107
- request. A change to a stable export counts unless all three jobs pass with each CLI's `main` unchanged; its
108
- changelog still lists it under "Changed — may break callers" for outside users. It counts when a job fails, when it
109
- passes only against a CLI branch made for the change, and when it compiles but changes behaviour that any CLI must
110
- adapt to. Parity cannot see that last case, so when in doubt the change counts. Gather the changes that count into
111
- one release a week, and bump the library in every CLI that uses it on the same day.
112
- - **Exceptions:** none.
113
- - **Applies to:** shared libraries. **Since:** cli-base 0.41.0. **Rulings:** NEED-473, NEED-1552.
93
+ - **Why:** a wait between releases or a weekly limit on breaking changes only delays a fix; the owner removed both
94
+ limits on 2026-10-24.
95
+ - **Enforced by:** review only.
96
+ - **Example:** tg-cli can release an hour after its last version, and cli-core can ship a breaking change on the same
97
+ day as the previous one. Bump the library in every CLI that uses it on the day it ships.
98
+ - **Exceptions:** none. The release lock (WC-REL-3) still keeps two releases on one machine from running at once.
99
+ - **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.52.0. **Rulings:** NEED-1741.
100
+
101
+ ### WC-REL-9 · Merged into [WC-REL-8](#wc-rel-8--any-package-may-release-at-any-time)
114
102
 
115
103
  ### WC-REL-10 · A messenger CLI is released only with a release report the owner signed for that commit
116
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.50.4",
3
+ "version": "0.51.1",
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",