@lorekit/cli 1.55.3 → 1.57.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.
@@ -0,0 +1,154 @@
1
+ // The Surface-Partner Map — a declarative registry of known, path-keyed file
2
+ // partnerships in this repo. This is slice 1 of the fix for a structural
3
+ // retrieval leak the `dash0-dev` review bot repeatedly flags: a fix to one
4
+ // surface (a mirrored module, a doc that copies a claim, a generated artifact,
5
+ // a required test assertion) leaves its PARTNER surface stale, because the
6
+ // lessons documenting each partnership are retrieved lexically (FTS +
7
+ // recency) and rarely surface at edit time for the exact file just touched.
8
+ //
9
+ // Every entry here is SEEDED from an existing, real CI guard — it duplicates
10
+ // no enforcement, it just makes the partnership visible to `lorekit
11
+ // obligations` (see `../commands/obligations.mjs`) BEFORE the guard runs, so
12
+ // a human or agent can sweep the partner in the same commit instead of
13
+ // discovering the gap in review. `guard` names that real spec/script per
14
+ // entry; entries with no automated guard (`perf-index`, `error-code-doc`) say
15
+ // so explicitly rather than implying one exists.
16
+ //
17
+ // Entry schema:
18
+ // {
19
+ // id: string, // stable identifier
20
+ // match: string|string[], // repo-relative glob(s); a `re:` prefix is
21
+ // // used as a verbatim RegExp; `**/{name}` or
22
+ // // `{name}` binds the matched module's
23
+ // // relative path (dirs + stem, extension
24
+ // // stripped) for reuse in `obliges`
25
+ // obliges: Array<string | string[]>,
26
+ // // each element is EITHER a required partner
27
+ // // path/glob (may reuse `{name}`), a
28
+ // // `run:<action>` advisory (never gates
29
+ // // --strict), OR an array of alternative
30
+ // // paths satisfied by ANY ONE of them (used
31
+ // // when a name-derived module's real partner
32
+ // // could live under either of two sibling
33
+ // // directories)
34
+ // lessonKey: string, // canonical memory key to cite
35
+ // guard?: string, // the existing CI spec/script that enforces
36
+ // // this partnership, if any
37
+ // note?: string, // why this entry exists / its limitations
38
+ // }
39
+ //
40
+ // See `obligations-pure.mjs` for the matcher and the exact `{name}` grammar.
41
+ //
42
+ // The `edge-mirror` / `edge-mirror-core` rows below are a special case: they
43
+ // are GENERATED, one pair of rows per entry in `mirrorPairs`
44
+ // (`./mirror-pairs.mjs`, the single-source inventory shared with
45
+ // `packages/mcp-core/src/edge/edge-parity.spec.ts`), rather than hand-authored
46
+ // with a `{name}`-substituting glob. A glob assuming the edge copy mirrors
47
+ // mcp-core's directory structure false-positives whenever a real mirror
48
+ // flattens or renames it (e.g. `packages/mcp-core/src/auth/auth-token.ts` ↔
49
+ // the FLAT `supabase/functions/mcp/auth-token.ts`) — the exact-path
50
+ // reconstruction it would need to get right is simply not derivable from the
51
+ // two paths alone. Enumerating each KNOWN pair's real partner has no such
52
+ // assumption to violate. `checkObligations` already merges every map entry
53
+ // that shares an `id` into one reported bucket (see `obligations-pure.mjs`'s
54
+ // `byId` merge in the matcher), so the many generated rows below still
55
+ // surface as the two logical `edge-mirror` / `edge-mirror-core` entries.
56
+
57
+ import { mirrorPairs } from './mirror-pairs.mjs';
58
+
59
+ // The flagship recurrence class: a partner copies a CLAIM (a mirrored
60
+ // module's behavior, a generated artifact's content, a documented mechanism)
61
+ // and goes stale when the source changes. Cited by every entry below whose
62
+ // obligation is "this partner copies what you just changed."
63
+ const COPIES_A_CLAIM_LESSON =
64
+ 'implement-suggestion-lessons::a-mechanism-clause-you-correct-in-the-pr-body-must-be-corrected-in-every-doc-that-copies-it';
65
+
66
+ // The registered-everywhere / sibling-set recurrence class: adding or moving
67
+ // something that a SET of surfaces enumerates (a doc listing every command,
68
+ // a generated mirror listing every file) re-flags every surface that lists
69
+ // the set and now has a hole.
70
+ const SIBLING_SET_LESSON = 'aw-lessons::docs-drift-grep-must-search-names-not-invocation';
71
+
72
+ const EDGE_MIRROR_GUARD = 'packages/mcp-core/src/edge/edge-parity.spec.ts';
73
+
74
+ // One `{ match, obliges }` row PER KNOWN PAIR, in both directions — see the
75
+ // module-level note above for why this replaces a `{name}`-templated glob.
76
+ const EDGE_MIRROR_ENTRIES = mirrorPairs.flatMap(({ core, edge }) => [
77
+ {
78
+ id: 'edge-mirror',
79
+ match: edge,
80
+ obliges: [core],
81
+ lessonKey: COPIES_A_CLAIM_LESSON,
82
+ guard: EDGE_MIRROR_GUARD,
83
+ note: 'An edge (Deno) module mirrored self-contained from mcp-core — edit one, mirror the other. Partner looked up from the shared mirror-pairs inventory, never reconstructed from an assumed-symmetric path.',
84
+ },
85
+ {
86
+ id: 'edge-mirror-core',
87
+ match: core,
88
+ obliges: [edge],
89
+ lessonKey: COPIES_A_CLAIM_LESSON,
90
+ guard: EDGE_MIRROR_GUARD,
91
+ note: 'The reverse direction of edge-mirror — a mcp-core source file changed, its known edge mirror (from the same mirror-pairs inventory) is the partner.',
92
+ },
93
+ ]);
94
+
95
+ export const SURFACE_PARTNER_MAP = [
96
+ ...EDGE_MIRROR_ENTRIES,
97
+ {
98
+ id: 'tool-catalog',
99
+ match: 'packages/schemas/src/shared/tool-catalog.ts',
100
+ obliges: [
101
+ 'supabase/functions/mcp/tool-dispatch.generated.ts',
102
+ 'packages/cli/src/surfaces.generated.mjs',
103
+ 'packages/web/public/llms.txt',
104
+ 'run:pnpm nx generate:llms schemas',
105
+ ],
106
+ lessonKey: COPIES_A_CLAIM_LESSON,
107
+ guard: 'packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts, scripts/codegen/gen-surfaces.mjs --check',
108
+ note: 'The catalog is the single origin of the operation surface — every generated projection of it must be regenerated in the same commit.',
109
+ },
110
+ {
111
+ id: 'llms-generated',
112
+ match: ['packages/schemas/src/llms/template.md', 'packages/schemas/src/shared/tool-catalog.ts'],
113
+ obliges: ['packages/web/public/llms.txt', 'run:pnpm nx generate:llms schemas'],
114
+ lessonKey: COPIES_A_CLAIM_LESSON,
115
+ guard: 'packages/schemas/src/llms/render.spec.ts',
116
+ note: 'llms.txt is GENERATED — never hand-edited; the committed file must be what the generator produces from these two sources.',
117
+ },
118
+ {
119
+ id: 'docs-section',
120
+ match: 're:^packages/web/src/content/docs/[^/]+\\.mdx$',
121
+ obliges: ['packages/web/src/lib/docs/sections.ts'],
122
+ lessonKey: SIBLING_SET_LESSON,
123
+ guard: 'packages/web/src/lib/docs/sections.spec.ts',
124
+ note: 'A new/removed docs page needs its DOCS_SECTIONS entry, or the site index and the page itself drift apart.',
125
+ },
126
+ {
127
+ id: 'plugin-skill',
128
+ match: 'packages/cli/skill/**',
129
+ obliges: ['run:node scripts/codegen/sync-plugin-skill.mjs', 'plugins/lorekit-claude/skills/**'],
130
+ lessonKey: SIBLING_SET_LESSON,
131
+ guard: 'scripts/codegen/sync-plugin-skill.mjs --check',
132
+ note: 'The Claude plugin vendors a copy of every skill/* source — regenerate the mirror, never hand-edit it.',
133
+ },
134
+ {
135
+ id: 'perf-index',
136
+ match: 're:^supabase/migrations/.*index.*\\.sql$',
137
+ obliges: ['supabase/tests/migrations.test.sql'],
138
+ lessonKey: SIBLING_SET_LESSON,
139
+ guard: null,
140
+ note: 'Convention only — a real gap. A new index migration has no automated nudge to add its coverage to the migrations test.',
141
+ },
142
+ {
143
+ id: 'error-code-doc',
144
+ match: ['supabase/functions/mcp/mcp-handler.ts', 'packages/mcp-core/src/auth/account-wide-tools.ts'],
145
+ obliges: [
146
+ 'docs/mcp-tools.md',
147
+ 'packages/schemas/src/llms/template.md',
148
+ 'packages/web/public/llms.txt',
149
+ ],
150
+ lessonKey: COPIES_A_CLAIM_LESSON,
151
+ guard: null,
152
+ note: 'A documented path-proxy, not a content predicate — obligations sees file paths, not diffs, so it cannot tell an error-const edit from an unrelated one in the same file and may over-flag. Advisory only (never gates unless --strict, and even then it is one signal among several).',
153
+ },
154
+ ];
@@ -0,0 +1,246 @@
1
+ // Pure, DEPENDENCY-FREE matcher for the Surface-Partner Map (`obligations-map.mjs`).
2
+ //
3
+ // The problem this solves: a fix to one surface (a mirrored module, a doc that
4
+ // copies a claim, a generated artifact) routinely leaves its PARTNER surface
5
+ // stale, because the ~45 memory lessons documenting each known partnership are
6
+ // retrieved lexically (FTS + recency) and never surface at edit time for a
7
+ // specific file. Since the recurrences are known, PATH-KEYED file
8
+ // partnerships, a deterministic path-keyed check beats search — this module is
9
+ // that check.
10
+ //
11
+ // Zero imports on purpose (mirrors `lessons-pure.mjs`) and NEVER touches the
12
+ // filesystem or `process.cwd()` — see the module-level note in
13
+ // `obligations-map.mjs`. It matches the path STRINGS it is given against the
14
+ // map; the IO shell (`commands/obligations.mjs`) resolves those strings from
15
+ // argv/stdin.
16
+ //
17
+ // ── the `{name}` placeholder ────────────────────────────────────────────────
18
+ //
19
+ // A `match`/`obliges` glob may contain the literal token `{name}` to bind the
20
+ // module's relative path (directories + file stem, extension stripped) so a
21
+ // generic partnership (`edge-mirror`) doesn't need one map entry per module.
22
+ // The token is recognised in two shapes:
23
+ //
24
+ // `**/{name}` — ONE capturing group spanning zero or more path segments
25
+ // AND the final stem, e.g. `src/**/{name}.ts` captures
26
+ // `audit/audit` out of `src/audit/audit.ts`, for reuse in a
27
+ // partner pattern's own `**/{name}` slot. This ONLY works
28
+ // when the partner's directory structure is a predictable
29
+ // function of the source's — e.g. `docs-section`'s
30
+ // `sections.ts` entry needs no name at all, so it's not used
31
+ // there either. It is NOT used by the seed map's
32
+ // `edge-mirror`/`edge-mirror-core` entries (see
33
+ // `obligations-map.mjs`): a real edge (Deno) mirror does not
34
+ // reliably preserve mcp-core's directory structure — it may
35
+ // flatten or rename it — so those two entries instead
36
+ // enumerate every KNOWN pair's exact partner from a
37
+ // single-source inventory (`mirror-pairs.mjs`) rather than
38
+ // reconstruct a partner path via substitution. The token
39
+ // remains available here as a general primitive for any
40
+ // future map entry whose partner path genuinely IS a
41
+ // predictable function of the source's.
42
+ // `{name}` — a single-segment capturing group (no preceding `**/`) for
43
+ // a flat `{name}.ext` match with no directory component.
44
+ //
45
+ // `stemOf` (below) is the simpler, general-purpose primitive — a plain
46
+ // basename-without-extension — exported for standalone use and unit testing.
47
+
48
+ export const RUN_PREFIX = 'run:';
49
+ export const REGEX_PREFIX = 're:';
50
+
51
+ const NAME_TOKEN = '{name}';
52
+ const DIR_NAME_TOKEN = `**/${NAME_TOKEN}`;
53
+
54
+ // Basename of `path`, extension stripped. A leading-dot file (`.env`) is its
55
+ // own stem (no extension to strip) rather than an empty string.
56
+ export function stemOf(path) {
57
+ const base = String(path ?? '').split('/').pop() ?? '';
58
+ const dot = base.lastIndexOf('.');
59
+ return dot > 0 ? base.slice(0, dot) : base;
60
+ }
61
+
62
+ // Escape one character for literal inclusion in a RegExp source string.
63
+ function escapeChar(ch) {
64
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
65
+ }
66
+
67
+ // Compile a single glob/regex pattern string into `{ regex, capturesName }`.
68
+ // `re:`-prefixed patterns are used verbatim (already anchored by the author,
69
+ // per the seed map's `docs-section`/`perf-index` entries) and never capture a
70
+ // name. Anything else is compiled token-by-token: `**/{name}` and bare
71
+ // `{name}` become the ONE capturing group a pattern may have, `**` becomes
72
+ // `.*`, `*` becomes `[^/]*`, and every other character is escaped literally.
73
+ // Returns null for a non-string / empty pattern, never throws.
74
+ export function compilePattern(pattern) {
75
+ if (typeof pattern !== 'string' || !pattern) return null;
76
+ if (pattern.startsWith(REGEX_PREFIX)) {
77
+ try {
78
+ return { regex: new RegExp(pattern.slice(REGEX_PREFIX.length)), capturesName: false };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ let src = '';
84
+ let capturesName = false;
85
+ let i = 0;
86
+ while (i < pattern.length) {
87
+ if (pattern.startsWith(DIR_NAME_TOKEN, i)) {
88
+ src += '(.+)';
89
+ capturesName = true;
90
+ i += DIR_NAME_TOKEN.length;
91
+ } else if (pattern.startsWith(NAME_TOKEN, i)) {
92
+ src += '([^/]+)';
93
+ capturesName = true;
94
+ i += NAME_TOKEN.length;
95
+ } else if (pattern.startsWith('**', i)) {
96
+ src += '.*';
97
+ i += 2;
98
+ } else if (pattern[i] === '*') {
99
+ src += '[^/]*';
100
+ i += 1;
101
+ } else {
102
+ src += escapeChar(pattern[i]);
103
+ i += 1;
104
+ }
105
+ }
106
+ return { regex: new RegExp(`^${src}$`), capturesName };
107
+ }
108
+
109
+ // Compile `glob` into a plain, non-capturing-aware `RegExp` — the general
110
+ // membership test used both to check whether a changed file satisfies a
111
+ // (post-substitution) oblige target, and by tests exercising the glob syntax
112
+ // in isolation. Same token grammar as `compilePattern`; a `{name}` left
113
+ // unsubstituted (never expected once `checkObligations` has run) simply
114
+ // compiles to its capturing form, still usable as a plain matcher.
115
+ export function globToRegExp(glob) {
116
+ return compilePattern(glob)?.regex ?? null;
117
+ }
118
+
119
+ // Replace the pattern's name placeholder with a captured value. `**/{name}`
120
+ // is replaced as ONE unit (so the substitution reconstructs a full relative
121
+ // path, matching how `compilePattern` captured it); a bare `{name}` is
122
+ // replaced on its own. `name == null` (no capture in the matching `match`
123
+ // pattern) returns `pattern` unchanged — nothing to substitute.
124
+ export function substituteName(pattern, name) {
125
+ if (name == null || typeof pattern !== 'string') return pattern;
126
+ if (pattern.includes(DIR_NAME_TOKEN)) return pattern.split(DIR_NAME_TOKEN).join(name);
127
+ if (pattern.includes(NAME_TOKEN)) return pattern.split(NAME_TOKEN).join(name);
128
+ return pattern;
129
+ }
130
+
131
+ // Does `file` satisfy `globPattern` (post-substitution, so no `{name}` token
132
+ // remains, but `**`/`*` wildcards may)? Compiles and tests in one step; an
133
+ // uncompilable pattern never matches.
134
+ function fileSatisfies(file, globPattern) {
135
+ const regex = globToRegExp(globPattern);
136
+ return regex ? regex.test(file) : false;
137
+ }
138
+
139
+ // Fold one `obliges` element into `bucket` (a `Map<target, row>` keyed by the
140
+ // rendered target string, so repeated matches — several changed files hitting
141
+ // the same entry — dedupe by the concrete, substituted target rather than by
142
+ // raw template).
143
+ //
144
+ // An element is one of:
145
+ // `run:<action>` — advisory, `kind:'action'`, `met: null`, never
146
+ // gates `--strict`.
147
+ // a string — a single required path/glob, `kind:'path'`.
148
+ // an array of strings — an "any of" group: the obligation is met if ANY
149
+ // candidate is present in `changedFiles` (for a
150
+ // partner that could legitimately live in more
151
+ // than one place). Rendered as one row whose
152
+ // `target` joins every candidate with ` OR `,
153
+ // `met` true iff any candidate matches. The seed
154
+ // map's `edge-mirror`/`edge-mirror-core` entries do
155
+ // NOT use this — see `obligations-map.mjs` — since
156
+ // enumerating each known pair's exact partner from
157
+ // `mirror-pairs.mjs` needs no "either of" guess.
158
+ // A row already present for the same rendered target has its `met` OR'd in
159
+ // (never downgraded from true to false by a later, differently-named match).
160
+ function addOblige(bucket, rawOblige, name, changedFiles) {
161
+ if (typeof rawOblige === 'string' && rawOblige.startsWith(RUN_PREFIX)) {
162
+ if (!bucket.has(rawOblige)) bucket.set(rawOblige, { target: rawOblige, kind: 'action', met: null });
163
+ return;
164
+ }
165
+ const candidates = (Array.isArray(rawOblige) ? rawOblige : [rawOblige])
166
+ .filter((c) => typeof c === 'string' && c);
167
+ if (candidates.length === 0) return;
168
+ const substituted = candidates.map((c) => substituteName(c, name));
169
+ const met = substituted.some((c) => changedFiles.some((f) => fileSatisfies(f, c)));
170
+ const target = substituted.join(' OR ');
171
+ const existing = bucket.get(target);
172
+ if (existing) existing.met = existing.met || met;
173
+ else bucket.set(target, { target, kind: 'path', met });
174
+ }
175
+
176
+ /**
177
+ * Check a changed-file set against the Surface-Partner Map. For every
178
+ * `(file, entry)` pair where `file` satisfies one of `entry.match`'s
179
+ * patterns, the entry is recorded as matched and its `obliges` are resolved
180
+ * (with `{name}` substituted from whatever the matching pattern captured) and
181
+ * checked for membership in `changedFiles`. Entries are deduped by `id` — a
182
+ * file set can hit the same entry via several files, and each contributes its
183
+ * own name-substituted targets into that one entry's `obliges` list.
184
+ *
185
+ * Pure and total: a malformed `map`/`changedFiles` degrades to no matches
186
+ * rather than throwing.
187
+ *
188
+ * Returns `{ files, matched, unmet, ok }` — see `obligations-map.mjs`'s
189
+ * schema docblock for the shape of each `matched` entry.
190
+ */
191
+ export function checkObligations({ changedFiles = [], map = [] } = {}) {
192
+ const files = Array.isArray(changedFiles)
193
+ ? changedFiles.filter((f) => typeof f === 'string' && f.length > 0)
194
+ : [];
195
+ const entries = Array.isArray(map) ? map : [];
196
+
197
+ const byId = new Map(); // id -> { id, lessonKey, guard, note, obliges: Map<target, row> }
198
+
199
+ for (const entry of entries) {
200
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
201
+ const patterns = Array.isArray(entry.match) ? entry.match : [entry.match];
202
+
203
+ for (const file of files) {
204
+ let matched = false;
205
+ let name = null;
206
+ for (const pattern of patterns) {
207
+ const compiled = compilePattern(pattern);
208
+ if (!compiled) continue;
209
+ const m = compiled.regex.exec(file);
210
+ if (m) {
211
+ matched = true;
212
+ if (compiled.capturesName) name = m[1];
213
+ break;
214
+ }
215
+ }
216
+ if (!matched) continue;
217
+
218
+ let bucket = byId.get(entry.id);
219
+ if (!bucket) {
220
+ bucket = {
221
+ id: entry.id,
222
+ lessonKey: entry.lessonKey ?? null,
223
+ guard: entry.guard ?? null,
224
+ note: entry.note ?? null,
225
+ obliges: new Map(),
226
+ };
227
+ byId.set(entry.id, bucket);
228
+ }
229
+ for (const rawOblige of Array.isArray(entry.obliges) ? entry.obliges : []) {
230
+ addOblige(bucket.obliges, rawOblige, name, files);
231
+ }
232
+ }
233
+ }
234
+
235
+ const matched = [...byId.values()].map((b) => ({
236
+ id: b.id,
237
+ lessonKey: b.lessonKey,
238
+ guard: b.guard,
239
+ note: b.note,
240
+ obliges: [...b.obliges.values()],
241
+ }));
242
+
243
+ const unmet = matched.reduce((n, e) => n + e.obliges.filter((o) => o.met === false).length, 0);
244
+
245
+ return { files, matched, unmet, ok: unmet === 0 };
246
+ }
@@ -5,7 +5,7 @@
5
5
  // otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
6
6
  // packages. One span + one counter data point per human-facing command
7
7
  // (install / uninstall / doctor / list / search / show / stats / scopes / diff /
8
- // tree / lint / dedupe / link / migrate), fired to Dash0 so the maintainers can
8
+ // tree / lint / dedupe / obligations / link / migrate), fired to Dash0 so the maintainers can
9
9
  // see which commands people actually run.
10
10
  //
11
11
  // Privacy — this runs on end-users' machines, so it is deliberately narrow:
@@ -655,25 +655,28 @@ function normalizeExitCode(result) {
655
655
  * Run a MACHINE-facing command (`hook`, `mcp`) and count the invocation —
656
656
  * counter only, no span. Returns the command's exit code unchanged.
657
657
  *
658
- * The export is STARTED BEFORE the command runs and awaited after, so it
659
- * overlaps the command's own work instead of being serialized behind it. That
660
- * ordering is what makes this affordable on `hook`, which fires several times
661
- * per agent turn: by the time there is anything to await, the POST has usually
662
- * already completed, and {@link METERED_TIMEOUT_MS} caps the worst case.
658
+ * The two commands are counted at DIFFERENT times, for a reason specific to
659
+ * each:
663
660
  *
664
- * It also makes the count robust for `mcp`, which is a LONG-LIVED stdio server —
665
- * `run()` does not return until the server exits, and a killed server would
666
- * never have reported at all if the export waited for it. Since the counter
667
- * reports the invocation rather than its outcome (a machine-facing command's
668
- * verdict belongs to its stdout contract, which the host reads), there is
669
- * nothing to learn by waiting.
661
+ * `mcp` is a LONG-LIVED stdio server — `run()` does not return until the
662
+ * server exits, and a killed server would never report at all if the export
663
+ * waited for it. So its count fires BEFORE run and is awaited after, which
664
+ * also overlaps the export with the server's own startup. It carries no
665
+ * outcome: a server's verdict belongs to its stdout contract.
666
+ * `hook` is short-lived and fires several times per agent turn. It is
667
+ * counted AFTER run so the counter can carry the health dimensions run
668
+ * reports (`lorekit.hook.event` + `lorekit.hook.outcome`) — the difference
669
+ * between a healthy hook and one silently degrading (an unusable store, a
670
+ * swallowed lookup error) that a bare invocation ping cannot show. The extra
671
+ * latency is run's own duration (a hook is tens of ms); the
672
+ * {@link METERED_TIMEOUT_MS} export cap dominates either way.
670
673
  *
671
674
  * Nothing here can affect the command: the exit code is passed through
672
675
  * untouched, and every telemetry failure is swallowed.
673
676
  *
674
677
  * @param {string} command `hook` | `mcp`
675
678
  * @param {string} version CLI version
676
- * @param {() => Promise<number>} run the command handler
679
+ * @param {() => Promise<number | { exitCode?: number, meter?: object }>} run the command handler
677
680
  */
678
681
  export async function meterCommand(command, version, run) {
679
682
  let config;
@@ -688,15 +691,26 @@ export async function meterCommand(command, version, run) {
688
691
  // cost nothing at all: no identity read, no timer, no promise.
689
692
  if (!config.enabled) return normalizeExitCode(await run());
690
693
 
691
- // `.catch` attached IMMEDIATELY, before any await: an unawaited rejecting
692
- // promise is an unhandled rejection, which on a machine-facing command would
693
- // print to stderr and pollute a host's log.
694
- const pending = countInvocation(config, command, version).catch(() => {});
695
- try {
696
- return normalizeExitCode(await run());
697
- } finally {
698
- await pending;
694
+ // Long-lived server: count before run (see docblock). `.catch` attached
695
+ // IMMEDIATELY, before any await: an unawaited rejecting promise is an
696
+ // unhandled rejection, which on a machine-facing command would print to
697
+ // stderr and pollute a host's log.
698
+ if (command === 'mcp') {
699
+ const pending = countInvocation(config, command, version).catch(() => {});
700
+ try {
701
+ return normalizeExitCode(await run());
702
+ } finally {
703
+ await pending;
704
+ }
699
705
  }
706
+
707
+ // Short-lived hook: count after run so the counter carries what run reported.
708
+ // A `meter` object on the result is the health payload; anything else (a bare
709
+ // exit code) counts with no extra dimensions.
710
+ const result = await run();
711
+ const meter = (result && typeof result === 'object' && result.meter) ? result.meter : {};
712
+ await countInvocation(config, command, version, meter).catch(() => {});
713
+ return normalizeExitCode(result);
700
714
  }
701
715
 
702
716
  /**
@@ -709,7 +723,7 @@ export async function meterCommand(command, version, run) {
709
723
  * `lint` finding) reports `lorekit.cli.outcome=failure` on a span the exporter
710
724
  * emits as STATUS_CODE_OK — never ERROR.
711
725
  *
712
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
726
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | obligations | link | migrate
713
727
  * @param {object} args parsed CLI args (read for allow-listed flags only)
714
728
  * @param {string} version CLI version (from package.json)
715
729
  * @param {() => Promise<number>} run the command handler