@lorekit/cli 1.39.1 → 1.40.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/README.md CHANGED
@@ -103,7 +103,8 @@ from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
103
103
  `install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
104
104
  fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
105
105
  tracked, only run `install --mcp-json` in that repo — a plain `install --project`
106
- would embed a live token in the now-committed file. See the
106
+ would embed a live token in the now-committed file (and the command warns before
107
+ it does). See the
107
108
  [Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
108
109
 
109
110
  In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
package/bin/lorekit.mjs CHANGED
@@ -542,18 +542,27 @@ HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embeddin
542
542
  measure — so it surfaces candidates for a human to review, and can both miss
543
543
  paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
544
544
 
545
+ Pass --cluster-by-key <regex> to cluster by KEY shape instead of value overlap:
546
+ entries whose keys share the same first capture group (or full match) form one
547
+ family. This catches coordinate-key debt — e.g. many pr{N}-{commentId} rows for
548
+ one review comment — that the value heuristic misses when the values differ.
549
+ Key-shape mode has no similarity cutoff, so --threshold and --cluster-by-key are
550
+ mutually exclusive: passing both is a usage error rather than a silent ignore.
551
+
545
552
  ${c.bold('Options')}
546
- -d, --dir <path> Target project root (default: current directory)
547
- --scope <scope> Restrict to a single scope (default: all applicable)
548
- --threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
549
- --json Machine-readable output (clusters + similarity signal)
550
- -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
551
- -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
552
- --store <path> Local project-tier store directory (default: .lorekit)
553
+ -d, --dir <path> Target project root (default: current directory)
554
+ --scope <scope> Restrict to a single scope (default: all applicable)
555
+ --threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
556
+ --cluster-by-key <re> Cluster by shared key capture instead of value overlap
557
+ --json Machine-readable output (clusters + signal)
558
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
559
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
560
+ --store <path> Local project-tier store directory (default: .lorekit)
553
561
 
554
562
  ${c.bold('Examples')}
555
563
  npx @lorekit/cli dedupe
556
564
  npx @lorekit/cli dedupe --threshold 0.6 --json
565
+ npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
557
566
  `,
558
567
  link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
559
568
 
@@ -652,7 +661,7 @@ const KNOWN_FLAGS = [
652
661
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
653
662
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
654
663
  // Scale-aware survey flags
655
- 'all', 'max', 'since', 'until', 'key-prefix',
664
+ 'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
656
665
  ];
657
666
 
658
667
  // Commands that write to disk / talk to the network on a human's behalf. These
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.39.1",
3
+ "version": "1.40.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.mjs CHANGED
@@ -591,7 +591,12 @@ export function tokenKind(token) {
591
591
  // avoid a circular import with mcp.mjs.
592
592
  //
593
593
  // A source that STORES a token wins outright (project beats global — closest
594
- // scope), but a TOKENLESS source must NOT shadow a later source that has one.
594
+ // scope), and it brings its OWN endpoint: a token authenticates one endpoint,
595
+ // so the two must travel together. A TOKENLESS source therefore never shadows a
596
+ // later source that has a token — not its token AND not its endpoint; its
597
+ // endpoint is only remembered as a fallback, used solely when NO source stores a
598
+ // token. (So "closest-scope-first" governs which token wins; the endpoint simply
599
+ // follows that token.)
595
600
  // That shadowing is exactly what `install --global --mcp-json` created: it
596
601
  // writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
597
602
  // AND the real token into ~/.claude.json. Returning early on the project entry
package/src/dedupe.mjs CHANGED
@@ -15,13 +15,18 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
15
15
  import { deriveScope } from './scope.mjs';
16
16
  import { resolveDenies } from './control.mjs';
17
17
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
- import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
19
- import { log, heading, status, c } from './util.mjs';
18
+ import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, clusterByKeyPattern, compileKeyPattern, DEFAULT_MAX } from './lessons-view.mjs';
19
+ import { log, heading, status, err, c } from './util.mjs';
20
20
 
21
21
  const DEFAULT_THRESHOLD = 0.8;
22
- // Maximum entries to accumulate before the token-blocking index becomes
23
- // memory-prohibitive. Beyond this the user must narrow via --key-prefix /
24
- // --since / --max.
22
+ // Maximum entries to accumulate before the survey becomes memory-prohibitive.
23
+ // It bounds the accumulated entry list itself, so it applies in BOTH modes —
24
+ // key-shape clustering is O(n) and needs no blocking index, but it still holds
25
+ // the whole population in memory, and an unbounded remote drain is the risk the
26
+ // cap exists for. In value mode it additionally bounds the token-blocking index,
27
+ // which is the super-linear part. Beyond the cap the results are genuinely
28
+ // partial in either mode, and the user must narrow via --key-prefix / --since /
29
+ // --max.
25
30
  const DEDUPE_POP_CAP = 2000;
26
31
 
27
32
  // Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
@@ -77,6 +82,34 @@ export async function dedupe(args) {
77
82
  args.threshold !== undefined
78
83
  ? parseThreshold(args.threshold)
79
84
  : (repoThreshold(root) ?? DEFAULT_THRESHOLD);
85
+
86
+ // `--cluster-by-key <regex>` switches from value-overlap clustering to KEY-shape
87
+ // clustering: entries whose keys share the same first capture group (or full
88
+ // match) of the regex are grouped as a duplicate family — catches coordinate-key
89
+ // debt (e.g. `bucket::pr{N}-{commentId}::slug`) that the Jaccard heuristic misses
90
+ // when the values differ. A bare flag (no value) or an unparseable regex is a
91
+ // usage error: report it and exit non-zero rather than silently surveying by value.
92
+ const clusterByKeyRaw = args['cluster-by-key'];
93
+ const byKeyMode = clusterByKeyRaw !== undefined;
94
+ const keyPattern = byKeyMode ? compileKeyPattern(clusterByKeyRaw) : null;
95
+ if (byKeyMode && !keyPattern) {
96
+ err(
97
+ clusterByKeyRaw === true
98
+ ? '--cluster-by-key needs a regex value, e.g. --cluster-by-key "(pr\\d+-\\d+)"'
99
+ : `--cluster-by-key: invalid regex ${JSON.stringify(String(clusterByKeyRaw))}`,
100
+ );
101
+ return { exitCode: 1 };
102
+ }
103
+ // Key-shape mode has no similarity cutoff, so an explicit `--threshold` would
104
+ // be silently ignored — the same silent fallback the bad-regex branch above
105
+ // refuses. Refuse it too. Only the EXPLICIT flag errors: a repo-level
106
+ // `dedupe.threshold` in .lorekit.json is a value-mode default, not a request,
107
+ // so it must not break a key-mode run.
108
+ if (byKeyMode && args.threshold !== undefined) {
109
+ err('--threshold is not used with --cluster-by-key (key-shape clustering has no similarity cutoff); drop one of them');
110
+ return { exitCode: 1 };
111
+ }
112
+
80
113
  const scopeInfo = deriveScope(root);
81
114
  // Default to every applicable scope; `--scope <s>` narrows to one.
82
115
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
@@ -156,7 +189,9 @@ export async function dedupe(args) {
156
189
  }
157
190
  return {
158
191
  available: true,
159
- clusters: clusterDuplicatesBlocked(entries, threshold),
192
+ clusters: byKeyMode
193
+ ? clusterByKeyPattern(entries, keyPattern)
194
+ : clusterDuplicatesBlocked(entries, threshold),
160
195
  errored: flat.errored,
161
196
  popCapped,
162
197
  };
@@ -164,7 +199,9 @@ export async function dedupe(args) {
164
199
  const { entries, errored, popCapped } = await streamAccumulate(store);
165
200
  return {
166
201
  available: true,
167
- clusters: clusterDuplicatesBlocked(entries, threshold),
202
+ clusters: byKeyMode
203
+ ? clusterByKeyPattern(entries, keyPattern)
204
+ : clusterDuplicatesBlocked(entries, threshold),
168
205
  errored,
169
206
  popCapped,
170
207
  };
@@ -188,12 +225,16 @@ export async function dedupe(args) {
188
225
  const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
189
226
 
190
227
  if (args.json) {
191
- log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
228
+ log(JSON.stringify(buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }), null, 2));
192
229
  } else {
193
230
  heading('LoreKit dedupe');
194
231
  log(` project: ${c.dim(root)}`);
195
232
  log(` scopes: ${scopes.join(' → ')}`);
196
- log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
233
+ log(
234
+ byKeyMode
235
+ ? ` ${c.dim(`key-shape: clustering by shared key capture of /${keyPattern.source}/`)}`
236
+ : ` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`,
237
+ );
197
238
 
198
239
  if (offlineSection.available && offlineSection.popCapped) {
199
240
  log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
@@ -211,7 +252,13 @@ export async function dedupe(args) {
211
252
  log('');
212
253
  const total = offlineClusters + remoteClusters;
213
254
  if (total === 0) {
214
- log(` ${c.green('✓')} no likely-duplicate clusters at this threshold`);
255
+ // "at this threshold" is only true in value mode — key-shape mode has no
256
+ // cutoff, so name the pattern that found nothing instead.
257
+ log(
258
+ byKeyMode
259
+ ? ` ${c.green('✓')} no key-shape clusters for /${keyPattern.source}/`
260
+ : ` ${c.green('✓')} no likely-duplicate clusters at this threshold`,
261
+ );
215
262
  } else {
216
263
  const plural = total === 1 ? '' : 's';
217
264
  log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
@@ -220,11 +267,14 @@ export async function dedupe(args) {
220
267
  }
221
268
 
222
269
  // Bounded, non-PII telemetry extras — counts + a boolean, never a scope
223
- // string, key, path, or token.
270
+ // string, key, path, or token. `threshold` is emitted only in value mode,
271
+ // where it is the cutoff actually applied; the key-mode pattern is user text
272
+ // and is never emitted, only the bounded mode name.
224
273
  return {
225
274
  exitCode: 0,
226
275
  'lorekit.cli.dedupe.scope_count': scopes.length,
227
- 'lorekit.cli.dedupe.threshold': threshold,
276
+ 'lorekit.cli.dedupe.mode': byKeyMode ? 'key' : 'value',
277
+ ...(byKeyMode ? {} : { 'lorekit.cli.dedupe.threshold': threshold }),
228
278
  'lorekit.cli.dedupe.offline_clusters': offlineClusters,
229
279
  'lorekit.cli.dedupe.remote_clusters': remoteClusters,
230
280
  'lorekit.cli.dedupe.remote_available': remoteAvailable,
@@ -255,25 +305,35 @@ function renderDedupeSection(header, section) {
255
305
  let n = 0;
256
306
  for (const cluster of section.clusters) {
257
307
  n += 1;
258
- const range =
259
- cluster.minSimilarity === cluster.maxSimilarity
260
- ? cluster.minSimilarity.toFixed(2)
261
- : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
262
- log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} memories, similarity ${range})`)}`);
308
+ // Key-shape clusters carry a `keyGroup`; value-overlap clusters carry a
309
+ // similarity range. Render whichever signal the cluster has.
310
+ let signal;
311
+ if (cluster.keyGroup !== undefined) {
312
+ signal = `${cluster.size} memories, key-group "${cluster.keyGroup}"`;
313
+ } else {
314
+ const range =
315
+ cluster.minSimilarity === cluster.maxSimilarity
316
+ ? cluster.minSimilarity.toFixed(2)
317
+ : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
318
+ signal = `${cluster.size} memories, similarity ${range}`;
319
+ }
320
+ log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${signal})`)}`);
263
321
  for (const m of cluster.members) {
264
322
  log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
265
323
  }
266
324
  }
267
325
  }
268
326
 
269
- // The `--json` payload: `{ root, scopes, threshold, offline, remote }` — each
270
- // store a `{ available, clusters: [{ members, size, minSimilarity,
271
- // maxSimilarity }], errored }` record (or an unavailable note).
272
- function buildJson({ root, scopes, threshold, offlineSection, remoteSection }) {
327
+ // The `--json` payload: `{ root, scopes, mode, threshold|keyPattern, offline,
328
+ // remote }`. In value mode (`mode: "value"`) each cluster carries `minSimilarity`
329
+ // / `maxSimilarity`; in key-shape mode (`mode: "key"`) each carries `keyGroup`.
330
+ // Each store is a `{ available, clusters, errored }` record (or an unavailable note).
331
+ function buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }) {
273
332
  return {
274
333
  root,
275
334
  scopes,
276
- threshold,
335
+ mode: byKeyMode ? 'key' : 'value',
336
+ ...(byKeyMode ? { keyPattern: keyPattern.source } : { threshold }),
277
337
  offline: sectionJson(offlineSection),
278
338
  remote: sectionJson(remoteSection),
279
339
  };
package/src/install.mjs CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  homeDir,
24
24
  mcpConfigPath,
25
25
  readJsonIfExists,
26
+ readLorekitServer,
27
+ isWebMcpServerEntry,
26
28
  } from './config.mjs';
27
29
  import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
28
30
  import { deriveScope } from './scope.mjs';
@@ -364,6 +366,21 @@ export async function install(args) {
364
366
  let file = null;
365
367
  let existed = false;
366
368
  if (!scopeWriteOwnedByWeb) {
369
+ // A plain project install embeds the token in .mcp.json. If that file
370
+ // currently holds the committable web form (written by an earlier
371
+ // `--mcp-json`), this write replaces it with an embedded secret — in the
372
+ // very file the docs tell you to commit. Warn before clobbering it;
373
+ // `isWebMcpServerEntry` recognises the shape we are about to overwrite.
374
+ if (scope === 'project' && token) {
375
+ const prior = readLorekitServer(root);
376
+ if (prior && isWebMcpServerEntry(prior.server)) {
377
+ status(
378
+ 'warn',
379
+ '.mcp.json',
380
+ `replacing the committable web entry with an embedded token — do not commit this file, or re-run with --mcp-json to keep the \${${WEB_TOKEN_ENV_VAR}} form`,
381
+ );
382
+ }
383
+ }
367
384
  ({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
368
385
  }
369
386
 
@@ -527,7 +544,7 @@ export async function install(args) {
527
544
  'warn',
528
545
  'token',
529
546
  `not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
530
- token ? ' (any --token you passed is not persisted)' : ''
547
+ plan.action === 'flag' ? ' (the token you supplied is not persisted)' : ''
531
548
  }`,
532
549
  );
533
550
  } else if (kind === 'none') {
@@ -430,6 +430,57 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
430
430
  return clusters;
431
431
  }
432
432
 
433
+ // Compile a `--cluster-by-key` pattern into a stateless RegExp, or null on bad
434
+ // input (empty / non-string / unparseable) — never throws, mirroring
435
+ // `parseThreshold`'s "never crash on bad input" contract. The source is always a
436
+ // STRING, so `new RegExp(raw)` carries no flags and is stateless by construction
437
+ // — flag stripping is only needed on the RegExp branch of `clusterByKeyPattern`,
438
+ // which can be handed a caller-built `/…/g`. Pure.
439
+ export function compileKeyPattern(raw) {
440
+ if (typeof raw !== 'string' || raw.length === 0) return null;
441
+ try {
442
+ return new RegExp(raw);
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ // Cluster entries by a shared KEY capture rather than value overlap. Two entries
449
+ // cluster iff their keys yield the SAME first capture group of `pattern` (or the
450
+ // same full match when the pattern has no capture group). This catches
451
+ // coordinate-key duplicate FAMILIES — many `bucket::pr{N}-{commentId}::slug` rows
452
+ // recorded for one review comment, whose slugs (and thus values) differ enough
453
+ // that `clusterDuplicates`'s Jaccard sweep never links them. Returns only 2+
454
+ // clusters, each `{ members: [{ scope, key }], size, keyGroup }`, largest first
455
+ // (ties broken by keyGroup for stable output). A key that doesn't match the
456
+ // pattern is left unclustered. Pure — O(n), no threshold.
457
+ export function clusterByKeyPattern(entries = [], pattern) {
458
+ const re =
459
+ pattern instanceof RegExp
460
+ ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
461
+ : compileKeyPattern(pattern);
462
+ if (!re) return [];
463
+ const byGroup = new Map();
464
+ for (const e of entries) {
465
+ const key = e.key == null ? '' : String(e.key);
466
+ re.lastIndex = 0;
467
+ const m = re.exec(key);
468
+ if (!m) continue;
469
+ const group = m[1] ?? m[0];
470
+ if (!byGroup.has(group)) byGroup.set(group, []);
471
+ byGroup.get(group).push({ scope: e.scope ?? null, key });
472
+ }
473
+ const clusters = [];
474
+ for (const [keyGroup, members] of byGroup) {
475
+ if (members.length < 2) continue;
476
+ clusters.push({ members, size: members.length, keyGroup });
477
+ }
478
+ clusters.sort(
479
+ (x, y) => y.size - x.size || String(x.keyGroup).localeCompare(String(y.keyGroup)),
480
+ );
481
+ return clusters;
482
+ }
483
+
433
484
  // Collect a store's non-archived entries for each scope, via the common
434
485
  // `store.list({scope})` contract. Returns ordered per-scope groups plus a total
435
486
  // — a per-scope read failure is captured on the group, never thrown, so one bad
package/src/telemetry.mjs CHANGED
@@ -39,6 +39,9 @@ const DEFAULT_DATASET = 'default';
39
39
  // Flags worth counting (e.g. how many installs are --global). Bounded on
40
40
  // purpose: only these booleans are ever attached, never free-form values.
41
41
  const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json', 'link'];
42
+ // One definition, used both to WRITE a flag attribute and to recognise one as
43
+ // reserved in `commandAttributes` — the two must not be able to drift.
44
+ const FLAG_ATTR_PREFIX = 'lorekit.cli.flag.';
42
45
 
43
46
  const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
44
47
 
@@ -245,17 +248,87 @@ function resourceAttributes(version, env = process.env) {
245
248
 
246
249
  // ── Payload builders (pure — unit-tested) ─────────────────────────────────────
247
250
 
251
+ /**
252
+ * The CLOSED vocabulary of `lorekit.cli.outcome`, and the one place it is
253
+ * written down in code.
254
+ *
255
+ * The three values are not synonyms and the distinction is load-bearing:
256
+ *
257
+ * - `ok` — ran, exit 0.
258
+ * - `failure` — RAN TO COMPLETION and reported a negative VERDICT (a failing
259
+ * `doctor` check, a `lint` finding). The command did its job.
260
+ * - `error` — CRASHED. This is the only one that also sets the span status to
261
+ * `STATUS_CODE_ERROR`.
262
+ *
263
+ * That is what keeps the `cli` service's error rate a measure of the CLI being
264
+ * broken rather than of the user's environment being unhealthy (see the note
265
+ * above the non-zero-exit branch in {@link traceCommand}).
266
+ *
267
+ * WHY A FROZEN CONSTANT RATHER THAN THREE STRING LITERALS. The values were only
268
+ * ever written inline, so the vocabulary was discoverable from the emitted
269
+ * telemetry and nowhere else — and read from telemetry alone the distinction is
270
+ * genuinely easy to misread. A `doctor` that CRASHED in one release and FAILED
271
+ * GRACEFULLY in the next shows up as `error` then `failure` for the same
272
+ * user-visible symptom, which reads like the attribute drifting when it is
273
+ * actually the CLI getting better. Naming the set makes the difference legible
274
+ * at the call site and gives `telemetry.test.mjs` something to pin the docs to.
275
+ */
276
+ export const CLI_OUTCOMES = Object.freeze({
277
+ OK: 'ok',
278
+ FAILURE: 'failure',
279
+ ERROR: 'error',
280
+ });
281
+
282
+ /** The same vocabulary as a value list, for guards and exhaustiveness checks. */
283
+ export const CLI_OUTCOME_VALUES = Object.freeze(Object.values(CLI_OUTCOMES));
284
+
285
+ /** The attribute keys `commandAttributes` owns — see its docblock below. */
286
+ const isReservedAttr = (key) =>
287
+ key === 'lorekit.cli.command' ||
288
+ key === 'lorekit.cli.outcome' ||
289
+ key === 'lorekit.cli.exit_code' ||
290
+ key.startsWith(FLAG_ATTR_PREFIX);
291
+
248
292
  /**
249
293
  * Collect the bounded, non-PII attributes for a command invocation. Only the
250
294
  * command name, allow-listed boolean flags, the outcome and the exit code.
295
+ *
296
+ * Deliberately does NOT validate `outcome`: this runs inside the `finally` of
297
+ * every traced command, where throwing would turn a telemetry problem into a
298
+ * command failure. The vocabulary is enforced at the call sites (all of which
299
+ * are in this file) and pinned by `telemetry.test.mjs`.
300
+ *
301
+ * The keys this function owns — command, outcome, exit code, flags — are a
302
+ * RESERVED NAMESPACE: an `extraAttrs` entry under one of them is dropped, and
303
+ * the owned value (if any) is written afterwards. `extraAttrs` used to be
304
+ * merged over last, which meant a command returning
305
+ * `{ exitCode, 'lorekit.cli.outcome': … }` silently replaced the frozen value on
306
+ * its way out — a runtime path the source scan cannot see, because it proves
307
+ * the literal at the call site and not that the value reaches the wire.
308
+ *
309
+ * Reserving the NAMESPACE rather than just overwriting key by key matters
310
+ * because two of the owned keys are written conditionally: `exit_code` only
311
+ * when `exitCode` is a number, and each flag only when it is truthy. Overwriting
312
+ * alone therefore left the gap open in exactly the cases where the CLI emits
313
+ * nothing — an extras value would have been the only `lorekit.cli.exit_code` on
314
+ * the span, sourced from the command rather than from here.
315
+ *
316
+ * A collision is dropped, not rejected: this runs inside the `finally` of every
317
+ * traced command, where throwing would turn a telemetry problem into a command
318
+ * failure. Losing a datum a command should not have put there is the smaller
319
+ * harm than emitting an unowned value under an owned key.
251
320
  */
252
321
  export function commandAttributes({ command, args = {}, outcome, exitCode, extraAttrs = {} }) {
253
- const attrs = { 'lorekit.cli.command': command, 'lorekit.cli.outcome': outcome };
322
+ const attrs = {};
323
+ for (const [key, value] of Object.entries(extraAttrs)) {
324
+ if (!isReservedAttr(key)) attrs[key] = value;
325
+ }
326
+ attrs['lorekit.cli.command'] = command;
327
+ attrs['lorekit.cli.outcome'] = outcome;
254
328
  if (typeof exitCode === 'number') attrs['lorekit.cli.exit_code'] = exitCode;
255
329
  for (const flag of FLAG_ATTRS) {
256
- if (args[flag]) attrs[`lorekit.cli.flag.${flag}`] = true;
330
+ if (args[flag]) attrs[`${FLAG_ATTR_PREFIX}${flag}`] = true;
257
331
  }
258
- Object.assign(attrs, extraAttrs);
259
332
  return attrs;
260
333
  }
261
334
 
@@ -389,7 +462,7 @@ export async function probeTelemetryExport(config, { version = '0.0.0', timeoutM
389
462
  name: 'lorekit.cli.doctor.telemetry_probe',
390
463
  attributes: {
391
464
  'lorekit.cli.command': 'doctor',
392
- 'lorekit.cli.outcome': 'ok',
465
+ 'lorekit.cli.outcome': CLI_OUTCOMES.OK,
393
466
  'lorekit.telemetry.probe': true,
394
467
  },
395
468
  startMs: now,
@@ -533,7 +606,7 @@ export async function traceCommand(command, args, version, run) {
533
606
  // `outcome` is the command's VERDICT (ok | failure | error); `status` is the
534
607
  // SPAN status, and only a crash sets it to error. See the note above the
535
608
  // non-zero-exit branch below.
536
- let outcome = 'ok';
609
+ let outcome = CLI_OUTCOMES.OK;
537
610
  let status = 'ok';
538
611
  let statusMessage;
539
612
  let extraAttrs = {};
@@ -563,11 +636,11 @@ export async function traceCommand(command, args, version, run) {
563
636
  // the CLI being broken rather than of the user's environment being
564
637
  // unhealthy. Query the failure verdicts on those attributes, never on the
565
638
  // span status.
566
- outcome = 'failure';
639
+ outcome = CLI_OUTCOMES.FAILURE;
567
640
  }
568
641
  return exitCode;
569
642
  } catch (e) {
570
- outcome = 'error';
643
+ outcome = CLI_OUTCOMES.ERROR;
571
644
  status = 'error';
572
645
  // Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
573
646
  // network error messages embed absolute paths (e.g. "ENOENT: ... open