@lorekit/cli 1.39.0 → 1.39.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.39.0",
3
+ "version": "1.39.2",
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": {
@@ -13,6 +13,10 @@ import { deriveScope } from '../scope.mjs';
13
13
  // must be able to reuse it rather than grow a second ranking with its own idea
14
14
  // of what "most useful" means.
15
15
  import { resolvePrecedence, rankLessons } from '../lessons-pure.mjs';
16
+ // The store's own scope inventory, normalised — the SAME helper `memory.scopes`
17
+ // uses, so the map and the MCP tool cannot disagree about what a scope holds or
18
+ // about what a failed enumeration looks like.
19
+ import { readScopeInventory } from '../store/scope-inventory.mjs';
16
20
  // The deep-link builder is the SAME pure module the `link` command and the
17
21
  // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
18
22
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
@@ -93,6 +97,19 @@ export const SCOPE_READ_LIMIT = 25;
93
97
 
94
98
  export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
95
99
  const scope = deriveScope(cwd);
100
+ // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
101
+ // inventory depends on the loop, so awaiting it afterwards would cost a
102
+ // remote store one extra SERIAL round-trip on the session-start path; started
103
+ // here it overlaps the per-scope reads instead. On a local store `listScopes`
104
+ // is synchronous under its async signature, so the overlap is nil there and
105
+ // the ordering is merely harmless.
106
+ //
107
+ // Leaving the promise unawaited across the loop is safe because
108
+ // `readScopeInventory` cannot reject: its "store cannot enumerate" guard
109
+ // returns before the `try`, and the `try` covers both a synchronous throw and
110
+ // a rejected `listScopes()`. Keep that property if either is ever touched — a
111
+ // floating promise that can reject would take the hook down with it.
112
+ const inventoryPromise = readScopeInventory(store);
96
113
  const groups = [];
97
114
  // Per scope: did the read come back full? Then the count below is a floor,
98
115
  // not a total, and the map must say so rather than quietly under-report.
@@ -152,11 +169,55 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
152
169
  // build its array.
153
170
  const ranked = rankLessons(winners, { terms: [], now, scopeOrder: scope.readOrder });
154
171
 
155
- // The scope map is built from the SAME pass, over the winners — the set a
156
- // reader could actually act on, so a shadowed duplicate is not counted twice.
157
- // Order follows `readOrder` (most-specific first) rather than count, because
158
- // the map is a map: it should read like the hierarchy it describes.
159
- const scopeCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
172
+ // ── the scope map: EXACT counts when the store can enumerate ───────────────
173
+ //
174
+ // The map's job is to tell a reader how much lore is sitting in each scope
175
+ // that this injection did not show them, so its numbers should be the store's
176
+ // real totals. Deriving them from the bounded read above cannot do that: the
177
+ // read stops at `SCOPE_READ_LIMIT`, so a scope holding 400 lessons reported
178
+ // `25+` — technically honest, useless as a quantity, and the `+` was doing a
179
+ // lot of work.
180
+ //
181
+ // `listScopes()` answers exactly, at any size, on BOTH stores: the local one
182
+ // walks its own tree, and the remote one hits `GET /memories/scopes`, which
183
+ // aggregates in Postgres (migration 00039) rather than counting rows a
184
+ // response cap may have truncated. That is the same reason `stats` and
185
+ // `scopes` were moved onto it.
186
+ //
187
+ // TWO SEMANTIC DIFFERENCES, both deliberate, because the map answers a
188
+ // different question from the injected set:
189
+ //
190
+ // 1. It counts EVERY active lesson in the scope, not just the ones that
191
+ // survived precedence. A key shadowed at a broader scope is still a real
192
+ // row you can `memory.read` there, and the map is a pointer to what
193
+ // exists — not a summary of what was injected.
194
+ // 2. It is not bounded by what this session happened to read, so a scope
195
+ // whose lessons all lost the ranking still appears, which is precisely
196
+ // when a reader most needs telling it is there.
197
+ //
198
+ // THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED. Exactness is not free
199
+ // on a local or two-tier store: `listScopes()` there is `_walkEntries()`
200
+ // (`store/local.mjs`), which reads and parses EVERY lesson file under the
201
+ // base dir — including the scopes outside `readOrder` that the narrowing
202
+ // below then throws away. So a session start now pays a store-wide walk where
203
+ // the derived counts cost nothing beyond the bounded read it already did. It
204
+ // is accepted deliberately: the walk is local disk over a store of markdown
205
+ // files, the hosted path aggregates in Postgres instead of walking anything,
206
+ // and the alternative — bounding the enumeration — reinstates the `25+` floor
207
+ // this change exists to remove. Narrowing the walk would mean a scope filter
208
+ // on the store contract, which is a change to all three implementations and
209
+ // to `memory.scopes`; if this ever shows up in a session-start profile, that
210
+ // is the fix, not a smaller limit here.
211
+ //
212
+ // Best-effort, like everything on this path: an unreachable remote, a store
213
+ // with no `listScopes`, or a throw all fall back to the derived counts —
214
+ // approximate and `+`-suffixed, exactly what shipped before — rather than
215
+ // costing the user their scope map. `readScopeInventory` never throws.
216
+ const inventory = await inventoryPromise;
217
+ const derivedCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
218
+ const scopeCounts = inventory.ok
219
+ ? scopeInventoryFromStore(inventory.scopes, scope.readOrder, derivedCounts)
220
+ : derivedCounts;
160
221
 
161
222
  // `applicable` is the honest denominator for the header — how many the reader
162
223
  // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
@@ -169,6 +230,49 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
169
230
  };
170
231
  }
171
232
 
233
+ // Per-scope counts from the STORE's own inventory, narrowed to the scopes this
234
+ // working directory reads and ordered by the hierarchy.
235
+ //
236
+ // The narrowing is the point: `listScopes()` is deliberately store-wide (it
237
+ // backs the `scopes` command, which enumerates everywhere), but the SessionStart
238
+ // map describes THIS workspace. Naming a scope the reader is not working in
239
+ // would be noise dressed up as guidance.
240
+ //
241
+ // An enumerated row is exact and so never carries `atReadLimit` — the `+`
242
+ // suffix exists to admit that a number is a floor, and an enumerated one is
243
+ // not. A row that fell back to the derived count keeps the flag it came with,
244
+ // because that number IS a floor. A scope with no active
245
+ // lesson is omitted, matching `scopeInventory`: a row reading `0` is noise, and
246
+ // there is nothing to drill into. Pure.
247
+ // `fallback` is the derived inventory — the same rows the failure path uses —
248
+ // and it is consulted PER SCOPE, not only when the whole enumeration failed.
249
+ // `ok: true` means the store answered, not that the answer is complete: a row
250
+ // can be missing, or carry a count that `shapeScopeRow` had to coerce to 0.
251
+ // Without the per-scope fallback a scope that just contributed injected lessons
252
+ // would drop off the map entirely — the reader would see lore in the digest
253
+ // with no row saying where it lives, which is a worse answer than the
254
+ // approximate one this had in hand all along.
255
+ export function scopeInventoryFromStore(scopes, scopeOrder = [], fallback = []) {
256
+ const counts = new Map();
257
+ for (const row of Array.isArray(scopes) ? scopes : []) {
258
+ const s = row?.scope;
259
+ if (!s) continue;
260
+ const n = Number(row.count);
261
+ if (Number.isFinite(n) && n > 0) counts.set(s, n);
262
+ }
263
+ const derived = new Map();
264
+ for (const row of Array.isArray(fallback) ? fallback : []) {
265
+ if (row?.scope) derived.set(row.scope, row);
266
+ }
267
+ return (Array.isArray(scopeOrder) ? scopeOrder : [])
268
+ .filter((s) => counts.has(s) || derived.has(s))
269
+ .map((s) => (counts.has(s)
270
+ ? { scope: s, count: counts.get(s), atReadLimit: false }
271
+ // The derived row keeps its own `atReadLimit`, so a fallen-back scope
272
+ // still renders `25+` rather than posing as an exact number.
273
+ : { ...derived.get(s) }));
274
+ }
275
+
172
276
  // Per-scope counts over an already-ranked lesson list, in the given scope order.
173
277
  // A scope with no surviving lesson is omitted — a map row reading `0` is noise,
174
278
  // and the reader cannot act on an empty scope. Pure.
@@ -29,6 +29,7 @@ import { loadControl } from './control.mjs';
29
29
  import { createStore } from './store/index.mjs';
30
30
  import { createRemoteStore } from './store/remote.mjs';
31
31
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
32
+ import { readScopeInventory } from './store/scope-inventory.mjs';
32
33
 
33
34
  const PROTOCOL_VERSION = '2024-11-05';
34
35
  const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
@@ -239,13 +240,15 @@ const MEMORY_DISPATCH = {
239
240
  // /memories/scopes` and the `lorekit scopes` command have answered this since
240
241
  // migration 00039; the MCP surface was the one caller that could not ask.
241
242
  //
242
- // THE TWO STORES ANSWER IN DIFFERENT SHAPES, and normalising here is the whole
243
- // job of this function. `LocalStore`/`TwoTierStore.listScopes()` return a BARE
244
- // ARRAY (`[{ scope, count }]`), while `RemoteStore.listScopes()` returns the
245
- // standard `{ ok, scopes }` envelope — or `{ ok: false, error, networkError,
246
- // unusable }`. A tool that passed either through verbatim would hand the model
247
- // two different contracts for one tool name depending on a config value it
248
- // cannot see.
243
+ // THE TWO STORES ANSWER IN DIFFERENT SHAPES, and reconciling them is NO LONGER
244
+ // this function's job it moved to `store/scope-inventory.mjs`, which the
245
+ // SessionStart scope map reads too. `LocalStore`/`TwoTierStore.listScopes()`
246
+ // return a BARE ARRAY (`[{ scope, count }]`), while `RemoteStore.listScopes()`
247
+ // returns the standard `{ ok, scopes }` envelope or `{ ok: false, error,
248
+ // networkError, unusable }`. A tool that passed either through verbatim would
249
+ // hand the model two different contracts for one tool name depending on a
250
+ // config value it cannot see. What is left here is what only the MCP surface
251
+ // owns: the ascending sort and the exit-clean degradation below.
249
252
  //
250
253
  // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
251
254
  // unreachable remote as a short note at exit 0 rather than failing the run. An
@@ -255,23 +258,16 @@ const MEMORY_DISPATCH = {
255
258
  // tool-level error is liable to retry it rather than carry on with the lore it
256
259
  // can already reach. The note says which, in bounded, non-PII terms.
257
260
  export async function listScopes(store) {
258
- let res;
259
- try {
260
- res = await store.listScopes();
261
- } catch (e) {
262
- // A store that cannot enumerate must not take the session down with it.
263
- return { ok: true, scopes: [], note: `scope enumeration failed: ${errText(e)}` };
264
- }
265
-
266
- // Local/two-tier: the bare array form.
267
- if (Array.isArray(res)) return { ok: true, scopes: sortScopes(res.map(shapeScope)) };
268
-
269
- // Remote: the envelope form.
270
- if (res && res.ok) {
271
- return { ok: true, scopes: sortScopes((Array.isArray(res.scopes) ? res.scopes : []).map(shapeScope)) };
272
- }
273
-
274
- return { ok: true, scopes: [], note: scopeFailureNote(res) };
261
+ // The array-vs-envelope branch and the failure vocabulary live in the shared
262
+ // `store/scope-inventory.mjs` — the SessionStart scope map needs the same
263
+ // normalisation, and two copies of "what does a failed enumeration look like"
264
+ // is how the two surfaces end up disagreeing about it.
265
+ const { ok, scopes, reason } = await readScopeInventory(store);
266
+ // `ok: true` either way: an enumeration that could not run is a fact about
267
+ // the store, not a failed tool call, so it must not reach `toolResult` as an
268
+ // `isError` a model is liable to retry instead of carrying on with the lore
269
+ // it can already reach.
270
+ return ok ? { ok: true, scopes: sortScopes(scopes) } : { ok: true, scopes: [], note: reason };
275
271
  }
276
272
 
277
273
  // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
@@ -301,42 +297,6 @@ function sortScopes(rows) {
301
297
  return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
302
298
  }
303
299
 
304
- // One inventory row. `last_activity` is passed through when the store supplied
305
- // it (the hosted `GET /memories/scopes` has returned it since migration 00049)
306
- // and OMITTED — never null — when it did not, so a client can tell "this store
307
- // does not report freshness" from "this scope has no activity".
308
- function shapeScope(s) {
309
- const scope = String(s?.scope ?? '');
310
- const count = Number(s?.count);
311
- const row = { scope, count: Number.isFinite(count) ? count : 0 };
312
- const last = s?.last_activity ?? s?.lastActivity;
313
- return last ? { ...row, last_activity: last } : row;
314
- }
315
-
316
- // A short, bounded reason an enumeration produced nothing. Deliberately built
317
- // here rather than reusing `lessons-view.mjs`'s `describeError`: that module
318
- // carries the whole render/`util` stack, and this server has kept clear of it.
319
- // The vocabulary matches what that helper reports, so the two read alike.
320
- function scopeFailureNote(res) {
321
- if (!res) return 'the store returned no result';
322
- if (res.unusable) return 'no usable store is configured';
323
- if (res.networkError) return `network error: ${String(res.networkError).slice(0, 200)}`;
324
- // `httpStatus` is the ONLY field that carries a real status: `restFetch`'s
325
- // error object is `{ message, code }`, and `code` is the response body's own
326
- // application code on a JSON error, so rendering it as "HTTP <code>" would
327
- // print a non-status. Read the top-level field first — that is the one
328
- // `RemoteStore.listScopes()` passes through — and keep the nested read as a
329
- // tolerance for any store that nests it instead.
330
- const status = res.httpStatus ?? res.error?.httpStatus;
331
- if (status) return `request failed with HTTP ${status}`;
332
- if (res.error?.message) return String(res.error.message).slice(0, 200);
333
- return 'the store could not enumerate its scopes';
334
- }
335
-
336
- function errText(e) {
337
- return String(e?.message ?? e).slice(0, 200);
338
- }
339
-
340
300
  // Provenance for a tool call: the caller's explicit values win, the working
341
301
  // directory and CI environment fill the rest. Best-effort — a failure to shell
342
302
  // out to git must never fail the write, so it degrades to no origin at all.
@@ -262,8 +262,8 @@ class RemoteStore {
262
262
  // where `code` is the response body's own application code on a JSON error
263
263
  // (a string like `permission_denied`) and only incidentally the status on a
264
264
  // non-JSON one. A consumer that wants to say "HTTP 403" must therefore read
265
- // `httpStatus`, never `error.code` — `mcp-server.mjs`'s `scopeFailureNote`
266
- // does exactly that, and it had nothing to read until this field was passed
265
+ // `httpStatus`, never `error.code` — `store/scope-inventory.mjs`'s
266
+ // `failureReason` does exactly that, and it had nothing to read until this field was passed
267
267
  // through. Additive: `scopes.mjs`, `stats.mjs` and `lessons-view.mjs` all
268
268
  // branch on `ok` / `unusable` / `networkError` and ignore the extra key.
269
269
  async listScopes() {
@@ -0,0 +1,123 @@
1
+ // Normalising a store's scope inventory into ONE shape.
2
+ //
3
+ // `listScopes()` is the only store method whose two implementations disagree
4
+ // about their envelope: `LocalStore`/`TwoTierStore` return a BARE ARRAY of
5
+ // `{ scope, count }`, while `RemoteStore` returns the standard
6
+ // `{ ok, scopes }` — or `{ ok: false, error, networkError, unusable }`. Every
7
+ // caller that wants an inventory therefore has to know which store it is
8
+ // holding, which is exactly the knowledge a caller should not need.
9
+ //
10
+ // Two callers now want one: the `memory.scopes` MCP tool and the SessionStart
11
+ // scope map. Before this module they each carried their own copy of the
12
+ // array-vs-envelope branch, which is two places to get "what does a failed
13
+ // enumeration look like" subtly different.
14
+ //
15
+ // TOTAL FUNCTION. Anything unrecognisable — a null, a rejected promise's value,
16
+ // a row with a missing count — degrades to a usable answer with a reason
17
+ // attached. Both callers are best-effort paths: the MCP tool must not turn "I
18
+ // could not enumerate" into a tool error, and the hook must not lose a lesson
19
+ // injection over a failed count. Neither can afford a throw.
20
+ //
21
+ // Zero-dependency: no imports, not even node builtins.
22
+
23
+ /**
24
+ * Normalise whatever `listScopes()` returned into `{ ok, scopes, reason }`.
25
+ *
26
+ * `ok` is false ONLY when the store could not answer. An empty store is a
27
+ * SUCCESSFUL enumeration that found nothing (`ok: true`, `scopes: []`) — the
28
+ * distinction matters, because "no scopes" and "I could not look" lead a caller
29
+ * to different behaviour, and collapsing them is how a transient network error
30
+ * ends up rendering as an authoritative empty inventory.
31
+ *
32
+ * `reason` is a short, bounded, non-PII note when `ok` is false, and null
33
+ * otherwise. The vocabulary matches `lessons-view.mjs`'s `describeError`, so
34
+ * the notes read alike wherever they surface — but it is built here rather than
35
+ * imported, because this module is reached from the SessionStart hot path and
36
+ * that one carries the whole render/`util` stack.
37
+ */
38
+ export function normalizeScopeInventory(result) {
39
+ // Local / two-tier: the bare array form.
40
+ if (Array.isArray(result)) return { ok: true, scopes: result.map(shapeScopeRow), reason: null };
41
+
42
+ // Remote: the envelope form.
43
+ if (result && result.ok) {
44
+ const scopes = Array.isArray(result.scopes) ? result.scopes : [];
45
+ return { ok: true, scopes: scopes.map(shapeScopeRow), reason: null };
46
+ }
47
+
48
+ return { ok: false, scopes: [], reason: failureReason(result) };
49
+ }
50
+
51
+ /**
52
+ * One inventory row, coerced.
53
+ *
54
+ * `last_activity` is passed through when the store supplied it (the hosted
55
+ * `GET /memories/scopes` has returned it since migration 00049) and OMITTED —
56
+ * never null — when it did not, so a consumer can tell "this store does not
57
+ * report freshness" from "this scope has none".
58
+ *
59
+ * ONE DELIBERATE DIVERGENCE from the `shapeScope` this replaced, named here so
60
+ * it is not mistaken for an accident: the count is clamped at 0, where the old
61
+ * helper passed a negative through. A negative count is not a quantity any
62
+ * store can honestly report — `LocalStore` increments a counter and the hosted
63
+ * route is a `count(*)` — so it can only ever be a malformed row, and `-3` in a
64
+ * `memory.scopes` answer is worse than `0`. Unreachable from either real store;
65
+ * it is the coercion boundary being total, not a behaviour anyone can observe.
66
+ */
67
+ export function shapeScopeRow(s) {
68
+ const count = Number(s?.count);
69
+ const row = { scope: String(s?.scope ?? ''), count: Number.isFinite(count) ? Math.max(0, count) : 0 };
70
+ const last = s?.last_activity ?? s?.lastActivity;
71
+ return last ? { ...row, last_activity: last } : row;
72
+ }
73
+
74
+ /** A short, bounded reason an enumeration produced nothing. */
75
+ export function failureReason(result) {
76
+ if (!result) return 'the store returned no result';
77
+ if (result.unusable) return 'no usable store is configured';
78
+ if (result.networkError) return `network error: ${clip(result.networkError)}`;
79
+ // `httpStatus` is the ONLY field carrying a real status: `restFetch`'s error
80
+ // object is `{ message, code }`, and `code` is the response body's own
81
+ // application code on a JSON error, so rendering it as "HTTP <code>" would
82
+ // print a non-status. Read the top-level field first — that is the one
83
+ // `RemoteStore.listScopes()` passes through — and keep the nested read as a
84
+ // tolerance for any store that nests it instead.
85
+ const status = result.httpStatus ?? result.error?.httpStatus;
86
+ if (status) return `request failed with HTTP ${status}`;
87
+ if (result.error?.message) return clip(result.error.message);
88
+ return 'the store could not enumerate its scopes';
89
+ }
90
+
91
+ function clip(v) {
92
+ return String(v).slice(0, 200);
93
+ }
94
+
95
+ /**
96
+ * Ask a store for its inventory without letting it fail the caller.
97
+ *
98
+ * A store need not implement `listScopes` at all (a stub, a fixture, a future
99
+ * adapter), and one that does may still throw. Both are ordinary here, so both
100
+ * come back as `ok: false` with a reason rather than as an exception the caller
101
+ * has to remember to catch.
102
+ *
103
+ * THE SECOND DELIBERATE DIVERGENCE from what `memory.scopes` used to answer,
104
+ * named here for the same reason as `shapeScopeRow`'s clamp. The old
105
+ * `mcp-server.mjs` path called `store.listScopes()` unguarded and let the
106
+ * resulting `TypeError` fall into its catch, so a store without the method
107
+ * reported `scope enumeration failed: store.listScopes is not a function` — an
108
+ * internal symbol leaked into a user-facing note, and indistinguishable from a
109
+ * store whose enumeration genuinely blew up. The missing method is checked
110
+ * first now and reported as `this store cannot enumerate scopes`; the throwing
111
+ * case keeps the `scope enumeration failed: <message>` wording verbatim. Two
112
+ * different facts, two different notes.
113
+ */
114
+ export async function readScopeInventory(store) {
115
+ if (!store || typeof store.listScopes !== 'function') {
116
+ return { ok: false, scopes: [], reason: 'this store cannot enumerate scopes' };
117
+ }
118
+ try {
119
+ return normalizeScopeInventory(await store.listScopes());
120
+ } catch (e) {
121
+ return { ok: false, scopes: [], reason: `scope enumeration failed: ${clip(e?.message ?? e)}` };
122
+ }
123
+ }
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