@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.
- package/README.md +29 -8
- package/package.json +1 -1
- package/skill/lorekit-groom/rules/grooming-pass.md +34 -0
- package/skill/lorekit-memory/SKILL.md +20 -2
- package/skill/lorekit-memory/references/scope-resolution.md +23 -1
- package/skill/lorekit-setup/SKILL.md +153 -92
- package/skill/lorekit-setup/rules/ci-state-records.md +1 -1
- package/skill/lorekit-setup/rules/cold-start-seeding.md +102 -0
- package/skill/lorekit-setup/rules/compiled-invariants.md +9 -8
- package/skill/lorekit-setup/rules/loop-health.md +117 -0
- package/skill/lorekit-setup/rules/proving-improvement.md +111 -0
- package/skill/lorekit-setup/rules/self-improvement-loops.md +196 -41
- package/skill/lorekit-setup/rules/team-and-portfolio.md +107 -0
- package/skill/lorekit-setup/templates/README.md +26 -0
- package/skill/lorekit-setup/templates/ci-job.md +90 -0
- package/skill/lorekit-setup/templates/code-changing-agent.md +89 -0
- package/skill/lorekit-setup/templates/multi-step-orchestrator.md +76 -0
- package/skill/lorekit-setup/templates/reviewer-reconcile-host.md +86 -0
- package/src/commands/invariants.mjs +17 -10
- package/src/commands/lint.mjs +4 -2
- package/src/commands/show.mjs +244 -3
- package/src/shared/candidates-pure.mjs +94 -5
- package/src/shared/lessons-view.mjs +71 -0
- package/src/shared/mirror-pairs.mjs +9 -0
- package/src/shared/scope-precedence.mjs +82 -0
- package/src/store/local.mjs +95 -1
- package/src/store/remote.mjs +60 -3
- package/src/surfaces.generated.mjs +14 -17
package/src/commands/show.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
311
|
+
if (!key) {
|
|
84
312
|
err(`${c.red('Usage:')} lorekit show <scope::key> [--json]`);
|
|
85
313
|
err(` lorekit show <scope> <key> [--json]`);
|
|
86
|
-
err(`
|
|
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
|
|
61
|
-
*
|
|
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 =
|
|
67
|
-
return
|
|
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) => ({
|
|
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
|
+
}
|