@lorekit/cli 1.68.0 → 1.70.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.
@@ -14,6 +14,12 @@
14
14
  // split (the shorthand already carries a namespaced
15
15
  // key, since the split lands at the first valid-scope
16
16
  // prefix — see `resolveScopeArg`)
17
+ // show <s1::k1> <s2::k2> [...]
18
+ // — multi-ref form: two or more positionals that EACH
19
+ // parse as a complete `<scope>::<key>` reference (see
20
+ // `isMultiRefForm` below). Batches through each
21
+ // store's `readMany`, one round-trip to the remote
22
+ // store rather than one request per ref.
17
23
  //
18
24
  // Uses each store's real `read({scope, key})` method (both stores expose it),
19
25
  // not a filtered `list` — a single-record lookup is what `read` is for, and it
@@ -29,9 +35,12 @@ import {
29
35
  shortDate,
30
36
  describeError,
31
37
  recordsDiverge,
38
+ resolveScopeArg,
32
39
  resolveScopeKeyArgs,
40
+ isScopeString,
33
41
  scopeIssue,
34
42
  } from '../shared/lessons-view.mjs';
43
+ import { SCOPE_PRECEDENCE } from '../shared/scope-precedence.mjs';
35
44
  import { resolveAppBase } from '../shared/deeplink-pure.mjs';
36
45
  import { emitLink } from './link.mjs';
37
46
  import { log, err, heading, status, c } from '../shared/util.mjs';
@@ -55,6 +64,193 @@ async function readOne(store, scope, key) {
55
64
  return { available: true, found: Boolean(record), record, error: null };
56
65
  }
57
66
 
67
+ // Is this a multi-ref invocation? Two or more positionals, no `--scope`/`--key`
68
+ // override (a flag is an explicit single-ref assertion — see
69
+ // `resolveScopeKeyArgs`), and EVERY positional parses as a complete
70
+ // `<scope>::<key>` reference via `resolveScopeArg`.
71
+ //
72
+ // This is what keeps the existing `show <scope> <key>` two-positional form
73
+ // unambiguous: its first positional is a BARE scope with no `::`, so
74
+ // `resolveScopeArg` reports it with a null key and the predicate below is
75
+ // false — the single-ref path runs unchanged, exactly as `show <scope::key>`
76
+ // (one positional) always has.
77
+ function isMultiRefForm(positionals, args) {
78
+ if (positionals.length < 2 || args.scope || args.key) return false;
79
+ return positionals.every((p) => resolveScopeArg(p, isScopeString).key !== null);
80
+ }
81
+
82
+ // Batch-read `refs` ({scope,key}[]) from one store via its `readMany`, and
83
+ // project the result back into ONE `{available, found, record, error}` slot
84
+ // per ref, in the SAME order the refs were given — mirroring what `readOne`
85
+ // answers for a single ref, so the per-ref renderer and `--json` builder don't
86
+ // need to know whether they are looking at a single read or a batch one.
87
+ //
88
+ // A transport-level failure (throw, or `{ ok:false }`) degrades EVERY ref in
89
+ // the batch to the same error note, exactly as a per-store outage would if
90
+ // each ref were read one at a time — a batch call doesn't get to fail more
91
+ // silently than the single-ref path it replaces.
92
+ async function readManyFrom(store, refs) {
93
+ let res;
94
+ try {
95
+ res = await store.readMany(refs);
96
+ } catch (e) {
97
+ const msg = (e && e.message) || 'error';
98
+ return refs.map(() => ({ available: true, found: false, record: null, error: msg }));
99
+ }
100
+ if (!res || res.ok === false) {
101
+ const msg = describeError(res);
102
+ return refs.map(() => ({ available: true, found: false, record: null, error: msg }));
103
+ }
104
+ return projectBatchResult(refs, res);
105
+ }
106
+
107
+ /**
108
+ * The line printed under a ref no store returned a record for — `null` when the
109
+ * ref WAS found, since the caller only asks about unresolved ones. Pure and
110
+ * exported so the three answers below are unit-testable.
111
+ *
112
+ * Three answers, not two. A slot carrying an `error` NEVER LOOKED — either the
113
+ * store dropped the ref (`REF_DROPPED_ERROR`) or the read itself failed — so
114
+ * its silence is not a "not found", and lumping it in with a store that did
115
+ * look states as absent something nobody checked. The mixed case is the whole
116
+ * reason this is not a boolean: the local store answers every ref it is given
117
+ * and never truncates, so past the remote's 32-ref cap the offline store says
118
+ * "no" while the remote never looked, and only naming both halves is accurate.
119
+ */
120
+ export function unresolvedReason(ref, slots) {
121
+ const readable = slots.filter((s) => s.available);
122
+ const dropped = readable.filter((s) => Boolean(s.error));
123
+ const answered = readable.length - dropped.length;
124
+ if (answered === 0) return `no store looked up ${ref} — it may exist`;
125
+ if (dropped.length > 0) {
126
+ return `${ref} not found in the store(s) that looked; ${dropped.length} did not look — it may exist there`;
127
+ }
128
+ return `no memory found for ${ref} in the readable store(s)`;
129
+ }
130
+
131
+ /** The message a ref the store never looked up carries. Exported for the spec. */
132
+ export const REF_DROPPED_ERROR =
133
+ 'not looked up — the store dropped this reference (past the 32-reference cap, or a scope it does not accept)';
134
+
135
+ /**
136
+ * Project a `readMany` result back onto the requested refs, in request order.
137
+ * Pure — no store, no IO — so the three outcomes below are unit-testable
138
+ * without a mock REST server (see CLAUDE.md on the loopback-HTTP flakiness).
139
+ *
140
+ * A ref the store ANSWERED is in exactly one of `entries` or `missing`. One in
141
+ * NEITHER was never looked up: the remote store drops refs past the 32-reference
142
+ * cap and refs whose scope its stricter grammar rejects, and neither loss
143
+ * reaches `missing`, which is a not-found list by design. Reporting those as
144
+ * `found: false` would print "no such key in this store" for a lesson that may
145
+ * well be there, so they carry an explicit error instead. The local store
146
+ * answers every ref it is given, so that branch is unreachable for it by
147
+ * construction rather than by a transport check.
148
+ */
149
+ export function projectBatchResult(refs, res) {
150
+ const byRef = new Map();
151
+ for (const entry of res.entries || []) {
152
+ const record = normalizeEntry(entry);
153
+ byRef.set(`${record.scope}::${record.key}`, record);
154
+ }
155
+ const answered = new Set(res.missing || []);
156
+ return refs.map(({ scope, key }) => {
157
+ const ref = `${scope}::${key}`;
158
+ const record = byRef.get(ref) || null;
159
+ if (!record && !answered.has(ref)) {
160
+ return { available: true, found: false, record: null, error: REF_DROPPED_ERROR };
161
+ }
162
+ return { available: true, found: Boolean(record), record, error: null };
163
+ });
164
+ }
165
+
166
+ // The multi-ref path: resolve every ref against both stores (one `readMany`
167
+ // round-trip per store, not one per ref) and report each in request order.
168
+ async function showRefs(refs, args, root, env) {
169
+ const { local, remote, connection } = resolveStores(root, {
170
+ env,
171
+ endpoint: args.endpoint,
172
+ token: args.token,
173
+ });
174
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
175
+
176
+ const offlineList = localDenied
177
+ ? refs.map(() => ({ available: false, reason: `disabled by deny constraint (${localDenied.source})` }))
178
+ : await readManyFrom(local, refs);
179
+
180
+ const remoteAvailable = !remoteDenied && remote.usable();
181
+ const remoteList = remoteDenied
182
+ ? refs.map(() => ({ available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }))
183
+ : remoteAvailable
184
+ ? await readManyFrom(remote, refs)
185
+ : refs.map(() => ({ available: false, reason: remoteUnavailableReason(connection) }));
186
+
187
+ const results = refs.map(({ scope, key }, i) => {
188
+ const offline = offlineList[i];
189
+ const remote_ = remoteList[i];
190
+ const foundOffline = Boolean(offline.available && offline.found);
191
+ const foundRemote = Boolean(remote_.available && remote_.found);
192
+ const diverged = foundOffline && foundRemote && recordsDiverge(offline.record, remote_.record);
193
+ const found = foundOffline || foundRemote;
194
+ // Which stores actually looked decides what a "not found" may claim.
195
+ const unresolved = found ? null : unresolvedReason(`${scope}::${key}`, [offline, remote_]);
196
+ const dropped = [offline, remote_].some((s) => s.available && Boolean(s.error));
197
+ return { scope, key, offline, remote_, foundOffline, foundRemote, diverged, found, unresolved, dropped };
198
+ });
199
+
200
+ if (args.json) {
201
+ log(JSON.stringify({
202
+ results: results.map((r) => buildJson({
203
+ scope: r.scope, key: r.key, offline: r.offline, remote_: r.remote_, diverged: r.diverged,
204
+ })),
205
+ }, null, 2));
206
+ } else {
207
+ heading('LoreKit memory');
208
+ log(` ${c.dim(`${results.length} references`)}`);
209
+ for (const r of results) {
210
+ log('');
211
+ log(` ${c.cyan(`${r.scope}::${r.key}`)}`);
212
+ renderRecordSection('Offline', r.offline);
213
+ renderRecordSection('Remote', r.remote_, remoteAvailable ? connection.endpoint : undefined);
214
+ if (r.diverged) status('warn', 'divergence', 'the offline and remote values differ');
215
+ // "not found" and "nobody looked" are different answers: say which.
216
+ if (r.unresolved) log(` ${c.dim(r.unresolved)}`);
217
+ }
218
+ log('');
219
+ }
220
+
221
+ const foundCount = results.filter((r) => r.found).length;
222
+ // Bounded, non-PII telemetry — counts only, never a scope or key string.
223
+ return {
224
+ exitCode: foundCount === results.length ? 0 : 1,
225
+ 'lorekit.cli.show.ref_count': results.length,
226
+ 'lorekit.cli.show.found_count': foundCount,
227
+ 'lorekit.cli.show.diverged_count': results.filter((r) => r.diverged).length,
228
+ // Refs at least one readable store never looked up — the batch hit the
229
+ // 32-ref cap, met a scope that store's grammar rejects, or the store
230
+ // errored. Without it a truncated batch is indistinguishable from a
231
+ // genuinely empty one: both report `found_count` below `ref_count`.
232
+ 'lorekit.cli.show.dropped_count': results.filter((r) => r.dropped).length,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Was this argument MEANT as a scope, even though it is not a valid one?
238
+ *
239
+ * The discriminator is the segment before the first `:` — if it is one of the
240
+ * four scope types, the caller was reaching for scope syntax and got it wrong
241
+ * (`global::`, `repo:noslash`, `branch::o/r`), and naming the scope is more
242
+ * useful than silently looking up a lesson keyed on that exact string.
243
+ *
244
+ * The case this must NOT catch is the reason the unscoped read exists:
245
+ * `pre-exec-lessons::automation-network-no-cli-blocks-everything` is a KEY that
246
+ * happens to contain `::`. Its first segment is not a scope type, so it reads
247
+ * as the key it is. A test for "does it contain `::`" would get that backwards.
248
+ */
249
+ function looksLikeScopeAttempt(token) {
250
+ const head = String(token).split(':')[0].toLowerCase();
251
+ return SCOPE_PRECEDENCE.includes(head);
252
+ }
253
+
58
254
  export async function show(args) {
59
255
  const root = resolveProjectRoot(args.dir);
60
256
  const env = { ...process.env };
@@ -67,10 +263,42 @@ export async function show(args) {
67
263
  // the `::` split (the shorthand handles a
68
264
  // namespaced key on its own now)
69
265
  const positionals = args._.slice(1);
70
- const { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
266
+
267
+ if (isMultiRefForm(positionals, args)) {
268
+ const refs = positionals.map((p) => resolveScopeArg(p, isScopeString));
269
+ return showRefs(refs, args, root, env);
270
+ }
271
+
272
+ let { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
71
273
  scope: args.scope,
72
274
  key: args.key,
73
275
  });
276
+ // A LONE positional that is neither a valid scope nor an ATTEMPT at one is a
277
+ // KEY, not a malformed scope.
278
+ //
279
+ // `resolveScopeKeyArgs` reports an unparseable first positional as the scope
280
+ // (with a null key) so its callers can name it as the malformed scope it is —
281
+ // right for `write`, which cannot proceed without knowing where to put the
282
+ // lesson. `show` can: a key alone resolves across every scope. So
283
+ // `lorekit show my-key` is a key-only lookup rather than "invalid scope
284
+ // my-key", which is what it used to say.
285
+ //
286
+ // Reinterpreted HERE rather than inside the shared parser: `write` and `link`
287
+ // must keep the strict reading, and the difference is about what each command
288
+ // can DO without a scope, not about how the arguments are spelled.
289
+ //
290
+ // Three things switch it off, each because the argument was plainly meant as
291
+ // a scope and a silent re-read would bury the user's actual mistake:
292
+ // - an explicit `--scope` (a flag is an assertion, so a bad one is an error)
293
+ // - MORE THAN ONE positional (`show foo bar` is the `<scope> <key>` form —
294
+ // `foo` is a scope that failed to parse, not a key with a stray argument)
295
+ // - a token that LOOKS like a scope attempt (`global::`, `repo:noslash`) —
296
+ // see `looksLikeScopeAttempt`.
297
+ if (!key && scope && !args.scope && positionals.length === 1
298
+ && !isScopeString(scope) && !looksLikeScopeAttempt(scope)) {
299
+ key = scope;
300
+ scope = '';
301
+ }
74
302
  // Scope validity is checked FIRST, for the same reason as in `write`: a bad
75
303
  // scope is the root cause, and "a key is required" is downstream noise.
76
304
  const badScope = scope ? scopeIssue(scope) : null;
@@ -80,10 +308,11 @@ export async function show(args) {
80
308
  err(`Run ${c.cyan('lorekit show --help')} for options.`);
81
309
  return 1;
82
310
  }
83
- if (!scope || !key) {
311
+ if (!key) {
84
312
  err(`${c.red('Usage:')} lorekit show <scope::key> [--json]`);
85
313
  err(` lorekit show <scope> <key> [--json]`);
86
- err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
314
+ err(` lorekit show <key> [--json] (searches every scope)`);
315
+ err(`A key is required. Run ${c.cyan('lorekit show --help')} for options.`);
87
316
  return 1;
88
317
  }
89
318
  // `show` consumes every positional it is given — unlike `write`, it has no
@@ -96,6 +325,18 @@ export async function show(args) {
96
325
  return 1;
97
326
  }
98
327
 
328
+ // `--link` is the one form that still needs a scope up front. It deliberately
329
+ // touches no store, and the deep link it prints is `?scope=…&lesson=…` — so
330
+ // without a scope there is nothing to resolve the key against and the URL
331
+ // would open the dashboard on an empty scope. Say so rather than emitting a
332
+ // link that goes nowhere.
333
+ if (args.link && !scope) {
334
+ err(`${c.red('Error:')} --link needs a scope — it builds a URL without reading any store.`);
335
+ err(`Pass ${c.cyan('lorekit show <scope>::' + key + ' --link')} or add ${c.cyan('--scope <s>')}.`);
336
+ err(`Run ${c.cyan('lorekit show ' + key)} to find which scope holds it.`);
337
+ return 1;
338
+ }
339
+
99
340
  // `--link` short-circuits: print the deep link that opens THIS lesson's detail
100
341
  // sheet (`?scope=…&lesson=…`) for the current args, without touching a store.
101
342
  if (args.link) {
@@ -44,6 +44,89 @@ export function parseMetaComment(value) {
44
44
  return out;
45
45
  }
46
46
 
47
+ const STATUS_TAG_PREFIX = 'status::';
48
+
49
+ /**
50
+ * A member's declared status. The canonical home is a `status::<value>` tag —
51
+ * first-class, filterable, and visible to a human reading the lesson — which is
52
+ * what the lorekit-setup skill now prescribes. A legacy `<!-- meta: status=… -->`
53
+ * comment in the body is still honoured so lessons written under the older
54
+ * convention keep their signal; the tag wins when both are present. Returns `''`
55
+ * when neither declares one.
56
+ */
57
+ export function statusOf(member) {
58
+ const tags = Array.isArray(member?.tags) ? member.tags : [];
59
+ for (const t of tags) {
60
+ if (typeof t === 'string' && t.startsWith(STATUS_TAG_PREFIX)) {
61
+ const v = t.slice(STATUS_TAG_PREFIX.length).trim();
62
+ if (v) return v;
63
+ }
64
+ }
65
+ const fromMeta = parseMetaComment(member?.value).status;
66
+ return typeof fromMeta === 'string' ? fromMeta.trim() : '';
67
+ }
68
+
69
+ // Is this line a bold LABEL, and which one? Returns `{ name, rest }` or null.
70
+ //
71
+ // Deliberately a line test rather than another alternative in a lookahead. Four
72
+ // separate defects in this function were one paragraph-terminating lookahead
73
+ // failing a different way each time — truncating at the first newline, then at
74
+ // any bold token, then failing to stop at a label whose colon sits OUTSIDE the
75
+ // bold (`**Why**:` rather than `**Why:**`). Each fix narrowed the failure
76
+ // without ending the class, because "where does this paragraph stop" is a
77
+ // line-level question and a single pattern answering it has to encode every
78
+ // spelling at once. Asked per line it is four lines of obvious code, and a new
79
+ // spelling is a new case here rather than a new branch inside a lookahead.
80
+ //
81
+ // A label carries a colon; bold alone does not. `**foo()** in the parser.` is
82
+ // prose continuing the paragraph, `**Why:**` and `**Why**:` both end it.
83
+ const BOLD_LEAD_RE = /^[ \t]*\*\*([^\n*]+?)\*\*[ \t]*(:?)[ \t]*/;
84
+
85
+ function boldLabel(line) {
86
+ const m = BOLD_LEAD_RE.exec(line);
87
+ if (!m) return null;
88
+ const [matched, inner, trailingColon] = m;
89
+ const innerColon = inner.endsWith(':');
90
+ if (trailingColon !== ':' && !innerColon) return null;
91
+ return {
92
+ name: (innerColon ? inner.slice(0, -1) : inner).trim().toLowerCase(),
93
+ rest: line.slice(matched.length),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * A member's applicability signal, printed verbatim and never interpreted. The
99
+ * canonical home is the visible `**Applies when:**` paragraph the lorekit-setup
100
+ * skill prescribes; a legacy `trigger-context` meta field is the fallback.
101
+ * Returns `''` when the lesson declares neither.
102
+ *
103
+ * Internal newlines are collapsed to single spaces — the only transformation,
104
+ * and a presentational one: the caller prints this as one field on one line, so
105
+ * a wrapped source paragraph must re-flow rather than break the record. No word
106
+ * is added, removed, or reordered, which is the sense in which it stays verbatim.
107
+ */
108
+ export function appliesWhenOf(member) {
109
+ const value = member?.value;
110
+ if (typeof value === 'string') {
111
+ const lines = value.split('\n');
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const label = boldLabel(lines[i]);
114
+ if (!label || label.name !== 'applies when') continue;
115
+ // The paragraph runs until a blank line, the next label, or the end.
116
+ const paragraph = [label.rest];
117
+ for (let j = i + 1; j < lines.length; j++) {
118
+ if (lines[j].trim() === '' || boldLabel(lines[j])) break;
119
+ paragraph.push(lines[j]);
120
+ }
121
+ const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
122
+ if (text) return text;
123
+ break; // declared but empty — fall through to the legacy field
124
+ }
125
+ }
126
+ const fromMeta = parseMetaComment(value)['trigger-context'];
127
+ return typeof fromMeta === 'string' ? fromMeta.trim() : '';
128
+ }
129
+
47
130
  function totalSeen(members) {
48
131
  return (members || []).reduce((n, m) => n + (Number.isFinite(m.seenCount) ? m.seenCount : 0), 0);
49
132
  }
@@ -57,14 +140,15 @@ function distinctScopeCount(members) {
57
140
  * summed `seenCount` across members crosses `minSeenCount` (default 3 — the
58
141
  * kickoff's "seen_count >= 3" criterion, applied to the SUM across the
59
142
  * cluster's members rather than any single one, since the whole pitch of a
60
- * candidate is "these N sightings are really one entry"), or a member's own
61
- * meta comment already declares a non-"active" status.
143
+ * candidate is "these N sightings are really one entry"), or a member already
144
+ * declares a non-"active" status (a `status::<value>` tag, or a legacy meta
145
+ * comment — see `statusOf`).
62
146
  */
63
147
  export function isCandidate(members, { minSeenCount = 3 } = {}) {
64
148
  if (totalSeen(members) >= minSeenCount) return true;
65
149
  return (members || []).some((m) => {
66
- const status = parseMetaComment(m.value).status;
67
- return typeof status === 'string' && status.length > 0 && status !== 'active';
150
+ const status = statusOf(m);
151
+ return status.length > 0 && status !== 'active';
68
152
  });
69
153
  }
70
154
 
@@ -90,7 +174,12 @@ export function rankCandidates(clusters, { minSeenCount = 3, resolveClass } = {}
90
174
  return (clusters || [])
91
175
  .filter((cl) => isCandidate(cl.members, { minSeenCount }))
92
176
  .map((cl) => {
93
- const members = (cl.members || []).map((m) => ({ ...m, meta: parseMetaComment(m.value) }));
177
+ const members = (cl.members || []).map((m) => ({
178
+ ...m,
179
+ meta: parseMetaComment(m.value),
180
+ status: statusOf(m),
181
+ appliesWhen: appliesWhenOf(m),
182
+ }));
94
183
  return {
95
184
  members,
96
185
  size: cl.size ?? members.length,
@@ -253,6 +253,36 @@ export function diffGroups(offline = {}, remote = {}) {
253
253
  // too terse to carry a durable observation (e.g. "yes", "fixed", "todo").
254
254
  export const MIN_VALUE_LEN = 12;
255
255
 
256
+ // Blank the INTERIOR of fenced code blocks (``` or ~~~ fences) so example content
257
+ // — a lesson documenting an `<!-- MARKER -->` or pasting a ```yaml front-matter
258
+ // sample — is never mistaken for hidden metadata. Such content renders as VISIBLE
259
+ // fenced text, the opposite of the digest-hidden block `hidden-metadata` targets,
260
+ // and `lint` is a CI gate, so a false positive there fails a legitimate lesson.
261
+ // Line positions are preserved (interiors and fence lines become empty) so the
262
+ // heading-anchored checks still see the real document structure. A closing fence
263
+ // is ≥3 of the SAME character as the opener with no trailing info string
264
+ // (CommonMark); an opener may carry an info string (```yaml). Pure.
265
+ function stripFencedCode(value) {
266
+ let fence = null; // the active fence character (` or ~), or null when outside a block
267
+ return String(value)
268
+ .split('\n')
269
+ .map((line) => {
270
+ const m = line.match(/^\s*(`{3,}|~{3,})/);
271
+ if (fence === null) {
272
+ if (m) {
273
+ fence = m[1][0];
274
+ return '';
275
+ }
276
+ return line;
277
+ }
278
+ if (m && m[1][0] === fence && line.trim().replace(/[`~\s]/g, '') === '') {
279
+ fence = null;
280
+ }
281
+ return '';
282
+ })
283
+ .join('\n');
284
+ }
285
+
256
286
  // The lint rule set: each a pure predicate over a normalized entry returning a
257
287
  // short reason string when it FIRES, or null when the entry is clean. Kept as
258
288
  // discrete named functions so each rule is independently unit-testable and the
@@ -335,6 +365,47 @@ export const LINT_RULES = {
335
365
  if (parsed === null || typeof parsed !== 'object') return null; // a bare scalar — short-value's to catch when short, otherwise unjudged.
336
366
  return 'value is a JSON object/array with no kind set — it renders as a raw JSON blob in every SessionStart digest; set --kind bus or --kind signal';
337
367
  },
368
+ // A lesson body is markdown for humans — no HTML comment, no front-matter, no
369
+ // `key=value` header. The `<!-- meta: seen_count=… status=… trigger-context=… -->`
370
+ // block is a REPUDIATED legacy convention this repo's lorekit-setup skill once
371
+ // prescribed (still parsed as a read-fallback in `candidates-pure.mjs` /
372
+ // `commands/invariants.mjs`, and skipped by the digest preview in `core/lessons.mjs`).
373
+ // It is wrong on the merits: an HTML comment renders to nothing, so a human sees a
374
+ // lesson starting mid-sentence, while a baked-in `seen_count`/`status`/`trigger`
375
+ // silently disagrees with the store's own column/tag/field. Conservative like the
376
+ // other rules — three concrete shapes only, never a fuzzy "looks like metadata":
377
+ // 1. any HTML comment (`<!--`), the strongest and most common offender;
378
+ // 2. a body that OPENS with a YAML front-matter block (`---` as the first line);
379
+ // 3. a machine-metadata header (`meta:`, `seen_count`, `status=`, `expires`,
380
+ // `ttl(_days)`, `trigger-context`) appearing BEFORE the first `#` title, so a
381
+ // legitimate prose line mid-body never trips it.
382
+ // All three run against a fence-stripped copy (`stripFencedCode`) so a lesson that
383
+ // DOCUMENTS one of these shapes inside a code block is never flagged.
384
+ 'hidden-metadata': (e) => {
385
+ const raw = String(e.value ?? '');
386
+ if (!raw.trim()) return null; // an empty value is `empty-value`'s to report.
387
+ const v = stripFencedCode(raw);
388
+ if (!v.trim()) return null; // nothing outside code fences — no prose metadata to flag.
389
+ if (v.includes('<!--')) {
390
+ return "value contains an HTML comment — lesson bodies are pure markdown; move seen_count → the column, status → a status:: tag, trigger → the trigger field";
391
+ }
392
+ const lines = v.split('\n');
393
+ const firstNonEmpty = lines.find((l) => l.trim() !== '');
394
+ if (firstNonEmpty !== undefined && firstNonEmpty.trim() === '---') {
395
+ return 'value opens with a front-matter block — lesson bodies carry no front-matter; every stored fact belongs in its own write field';
396
+ }
397
+ const headingIdx = lines.findIndex((l) => /^\s*#/.test(l));
398
+ if (headingIdx > 0) {
399
+ const metaHeader = lines
400
+ .slice(0, headingIdx)
401
+ .filter((l) => l.trim() !== '')
402
+ .find((l) => /^\s*(meta\b|seen_count\b|status\s*=|expires\b|ttl(?:_days)?\b|trigger[-_]context\b)/i.test(l));
403
+ if (metaHeader) {
404
+ return `value has a machine-metadata header before the title ('${metaHeader.trim().slice(0, 40)}') — move it to the store's own fields`;
405
+ }
406
+ }
407
+ return null;
408
+ },
338
409
  };
339
410
 
340
411
  // Run every lint rule against one normalized entry, returning the findings it
@@ -75,6 +75,11 @@ export const mirrorPairs = [
75
75
  { core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true },
76
76
  { core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true },
77
77
  { core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true },
78
+ // Which row an UNSCOPED read resolves to. Import-free on both sides (it only
79
+ // parses the scope prefix and compares strings), so the byte comparison
80
+ // applies — which is the point: a precedence that drifted between MCP and
81
+ // REST would hand the same key-only read two different lessons.
82
+ { core: 'packages/mcp-core/src/scope/scope-precedence.ts', edge: 'supabase/functions/_shared/scope/scope-precedence.ts', driftChecked: true },
78
83
  { core: 'packages/mcp-core/src/auth/account-wide-tools.ts', edge: 'supabase/functions/_shared/auth/account-wide-tools.ts', driftChecked: true },
79
84
  { core: 'packages/mcp-core/src/telemetry/io-ledger.ts', edge: 'supabase/functions/_shared/telemetry/io-ledger.ts', driftChecked: true },
80
85
  { core: 'packages/mcp-core/src/telemetry/db-query-metrics.ts', edge: 'supabase/functions/_shared/telemetry/db-query-metrics.ts', driftChecked: true },
@@ -91,6 +96,10 @@ export const mirrorPairs = [
91
96
  // `lessons-view.mjs` clustering, which is what `lorekit dedupe` runs — guarded
92
97
  // behaviourally by `duplicate-clusters-parity.spec.ts` instead.
93
98
  { core: 'packages/mcp-core/src/clusters/duplicate-clusters.ts', edge: 'supabase/functions/_shared/clusters/duplicate-clusters.ts', driftChecked: true },
99
+ // Batch `memory.read` (`refs`) core — grouping-by-scope, the key charset
100
+ // guard, and the found/missing set difference shared by MCP's `toolRead`
101
+ // and REST's `handleRead`. Import-free, so the byte comparison applies.
102
+ { core: 'packages/mcp-core/src/memory/read-refs.ts', edge: 'supabase/functions/_shared/memory/read-refs.ts', driftChecked: true },
94
103
  // Excluded from the byte-comparison drift check: the edge copy types the
95
104
  // client as `ReturnType<typeof createClient>` off an `npm:` specifier where
96
105
  // mcp-core imports a typed `SupabaseClient`, and additionally carries
@@ -0,0 +1,82 @@
1
+ // Which entry wins when a read named NO scope — the CLI's twin of
2
+ // `packages/mcp-core/src/scope/scope-precedence.ts` (and its edge mirror,
3
+ // `supabase/functions/_shared/scope/scope-precedence.ts`).
4
+ //
5
+ // Kept as a separate zero-dep module rather than imported: this package has no
6
+ // dependency on `@lorekit/core`, and the pair is a CROSS-LANGUAGE one (this
7
+ // `.mjs` vs that `.ts`), so it is guarded for BEHAVIOURAL parity by
8
+ // `scope-precedence-parity.spec.ts` rather than the byte comparison
9
+ // `mirror-pairs.mjs` runs over the two TypeScript copies — the same
10
+ // arrangement `session-kind-parity.spec.ts` uses.
11
+ //
12
+ // Distinct from `resolvePrecedence` in `lessons-pure.mjs`, which resolves a
13
+ // key across the CALLER'S OWN scope list (`deriveScope().readOrder`,
14
+ // narrow-to-broad). That one answers "the agent is standing in this repo, on
15
+ // this branch — whose lesson wins?". This one answers the question that only
16
+ // arises once NO scope was named at all, where the caller's own scopes are not
17
+ // the candidate set: every scope the account can see is. The server has no
18
+ // working directory to derive `readOrder` from, so this is the only definition
19
+ // MCP, REST and the CLI can all compute — which is why `show <key>` uses it
20
+ // too, instead of quietly resolving differently from the hosted surfaces.
21
+
22
+ /**
23
+ * Scope types in resolution order, most-specific first. The SAME order as
24
+ * `readOrder` — see the TS twin for why it is a list rather than a segment
25
+ * count.
26
+ */
27
+ export const SCOPE_PRECEDENCE = ['project', 'branch', 'repo', 'global'];
28
+
29
+ /**
30
+ * Rank a scope by type: 0 for the most specific, higher for broader. An
31
+ * unrecognised scope ranks LAST rather than throwing.
32
+ */
33
+ export function scopePrecedenceRank(scope) {
34
+ const raw = typeof scope === 'string' ? scope.trim().toLowerCase() : '';
35
+ const sep = raw.indexOf('::');
36
+ const prefix = sep === -1 ? raw : raw.slice(0, sep);
37
+ const at = SCOPE_PRECEDENCE.indexOf(prefix);
38
+ return at === -1 ? SCOPE_PRECEDENCE.length : at;
39
+ }
40
+
41
+ /**
42
+ * Order two candidates: precedence band, then most-recently-updated, then
43
+ * scope ascending. Total and deterministic — see the TS twin for why the last
44
+ * two tie-breaks are load-bearing rather than decoration.
45
+ */
46
+ export function compareScopePrecedence(a, b) {
47
+ const byRank = scopePrecedenceRank(a?.scope) - scopePrecedenceRank(b?.scope);
48
+ if (byRank !== 0) return byRank;
49
+ const at = typeof a?.updated_at === 'string' ? a.updated_at : '';
50
+ const bt = typeof b?.updated_at === 'string' ? b.updated_at : '';
51
+ if (at !== bt) return at < bt ? 1 : -1;
52
+ const as = typeof a?.scope === 'string' ? a.scope : '';
53
+ const bs = typeof b?.scope === 'string' ? b.scope : '';
54
+ return as < bs ? -1 : as > bs ? 1 : 0;
55
+ }
56
+
57
+ /** The entry an unscoped read resolves to, or `null` when there were none. */
58
+ export function pickScopeWinner(rows) {
59
+ if (!Array.isArray(rows) || rows.length === 0) return null;
60
+ let winner = rows[0];
61
+ for (let i = 1; i < rows.length; i += 1) {
62
+ if (compareScopePrecedence(rows[i], winner) < 0) winner = rows[i];
63
+ }
64
+ return winner;
65
+ }
66
+
67
+ /**
68
+ * Every scope the candidates cover EXCEPT the winner's, in precedence order,
69
+ * de-duplicated — what an unscoped read reports so the caller can see its key
70
+ * was ambiguous and pass an explicit scope next time.
71
+ */
72
+ export function shadowedScopes(rows, winner) {
73
+ if (!Array.isArray(rows) || !winner) return [];
74
+ const seen = new Set([winner.scope]);
75
+ const out = [];
76
+ for (const row of [...rows].sort(compareScopePrecedence)) {
77
+ if (seen.has(row.scope)) continue;
78
+ seen.add(row.scope);
79
+ out.push(row.scope);
80
+ }
81
+ return out;
82
+ }