@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/store/local.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import path from 'node:path';
|
|
|
10
10
|
import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
|
|
11
11
|
import { normalizeCreatedAt } from './created-at.mjs';
|
|
12
12
|
import { isLive, resolveExpiresAt } from './ttl.mjs';
|
|
13
|
-
import { seenCountOf, withReadFields } from './entry-fields.mjs';
|
|
13
|
+
import { seenCountOf, withReadFields, updatedAtOf } from './entry-fields.mjs';
|
|
14
|
+
import { pickScopeWinner } from '../shared/scope-precedence.mjs';
|
|
14
15
|
|
|
15
16
|
export function createLocalStore(baseDir) {
|
|
16
17
|
return new LocalStore(baseDir);
|
|
@@ -54,6 +55,20 @@ class LocalStore {
|
|
|
54
55
|
return this._readAll(scope).find((r) => r.entry.key === key) || null;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// Every LIVE entry carrying `key`, across every scope in this store — the
|
|
59
|
+
// candidate set for a read that named no scope.
|
|
60
|
+
//
|
|
61
|
+
// Walks the whole tree rather than one scope directory, because `_files`
|
|
62
|
+
// resolves a single scope→directory and there is no directory for "any".
|
|
63
|
+
// That is more IO than a scoped read, which is exactly why a caller that
|
|
64
|
+
// knows its scope should still pass it.
|
|
65
|
+
_findAllByKey(key) {
|
|
66
|
+
const now = new Date();
|
|
67
|
+
return this._walkEntries()
|
|
68
|
+
.map((r) => r.entry)
|
|
69
|
+
.filter((e) => e && e.key === key && e.scope && isLive(e, now));
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
// Raw lookup by scope+key — returns the stored entry regardless of archived
|
|
58
73
|
// state (unlike read(), which hides archived). Synchronous; used by migrate
|
|
59
74
|
// to classify ADD / UPDATE / NOOP without reviving archived entries.
|
|
@@ -80,7 +95,14 @@ class LocalStore {
|
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
// read({ scope, key }) → { ok, entry } — null when absent, archived, or expired.
|
|
98
|
+
//
|
|
99
|
+
// `scope` is OPTIONAL. Without one the key is resolved across every scope in
|
|
100
|
+
// this store and the winner picked by the shared `scope-precedence` rule —
|
|
101
|
+
// the SAME rule the hosted MCP and REST surfaces apply, so `lorekit show
|
|
102
|
+
// <key>` offline and `memory.read { key }` online cannot resolve the same key
|
|
103
|
+
// to different lessons.
|
|
83
104
|
async read({ scope, key } = {}) {
|
|
105
|
+
if (!scope) return { ok: true, entry: this._readUnscoped(key) };
|
|
84
106
|
const found = this._findByKey(scope, key);
|
|
85
107
|
return {
|
|
86
108
|
ok: true,
|
|
@@ -88,6 +110,34 @@ class LocalStore {
|
|
|
88
110
|
};
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
// The unscoped half of `read`, split out so `TwoTierStore` can reuse the
|
|
114
|
+
// candidate gathering without re-deciding the precedence rule.
|
|
115
|
+
_readUnscoped(key) {
|
|
116
|
+
const winner = pickScopeWinner(this._findAllByKey(key).map(scopeCandidate));
|
|
117
|
+
return winner ? withReadFields(winner.entry) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// readMany(refs) → { ok, entries, missing } — batch lookup for an array of
|
|
121
|
+
// `{ scope, key }` refs, mirroring the REST/MCP batch read's shape: found
|
|
122
|
+
// entries in `entries` (same projection `read()` applies), unresolved refs
|
|
123
|
+
// (absent, archived, or expired) reported as `scope::key` strings in
|
|
124
|
+
// `missing` rather than silently dropped. A thin loop over `_findByKey` —
|
|
125
|
+
// this store has no query engine to batch against, so there is no cheaper
|
|
126
|
+
// shape than one lookup per ref.
|
|
127
|
+
async readMany(refs = []) {
|
|
128
|
+
const entries = [];
|
|
129
|
+
const missing = [];
|
|
130
|
+
for (const { scope, key } of refs) {
|
|
131
|
+
const found = this._findByKey(scope, key);
|
|
132
|
+
if (found && isLive(found.entry)) {
|
|
133
|
+
entries.push(withReadFields(found.entry));
|
|
134
|
+
} else {
|
|
135
|
+
missing.push(`${scope}::${key}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, entries, missing };
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
// write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
|
|
92
142
|
// refreshes `updated`; writing an archived key revives it.
|
|
93
143
|
//
|
|
@@ -328,6 +378,19 @@ class LocalStore {
|
|
|
328
378
|
}
|
|
329
379
|
}
|
|
330
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Project a stored entry into the `{ scope, updated_at }` shape
|
|
383
|
+
* `scope-precedence` compares, keeping the entry itself alongside so the winner
|
|
384
|
+
* can be returned without a second lookup.
|
|
385
|
+
*
|
|
386
|
+
* `updatedAtOf` is what maps the local `updated` spelling onto the `updated_at`
|
|
387
|
+
* the shared rule expects — without it every local candidate would compare as
|
|
388
|
+
* having no timestamp and the tie-break would collapse to scope-ascending.
|
|
389
|
+
*/
|
|
390
|
+
function scopeCandidate(entry) {
|
|
391
|
+
return { scope: entry.scope, updated_at: updatedAtOf(entry), entry };
|
|
392
|
+
}
|
|
393
|
+
|
|
331
394
|
// A scope string is global when its type segment is `global`.
|
|
332
395
|
function isGlobalScope(scope) {
|
|
333
396
|
return String(scope).split('::')[0] === 'global';
|
|
@@ -394,6 +457,23 @@ class TwoTierStore {
|
|
|
394
457
|
}
|
|
395
458
|
|
|
396
459
|
async read({ scope, key } = {}) {
|
|
460
|
+
// Unscoped: precedence has to be decided over BOTH tiers at once, not tier
|
|
461
|
+
// by tier. Asking the project tier first and taking any hit would let a
|
|
462
|
+
// `global` lesson in the project tier beat a `repo::…` one in the home
|
|
463
|
+
// tier — tier order standing in for scope precedence, which is not what it
|
|
464
|
+
// means. Within ONE scope the project tier still shadows home, exactly as
|
|
465
|
+
// the scoped path below does.
|
|
466
|
+
if (!scope) {
|
|
467
|
+
const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
|
|
468
|
+
const byScope = new Map();
|
|
469
|
+
for (const tier of tiers) {
|
|
470
|
+
for (const entry of tier._findAllByKey(key)) {
|
|
471
|
+
if (!byScope.has(entry.scope)) byScope.set(entry.scope, entry);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const winner = pickScopeWinner([...byScope.values()].map(scopeCandidate));
|
|
475
|
+
return { ok: true, entry: winner ? withReadFields(winner.entry) : null };
|
|
476
|
+
}
|
|
397
477
|
if (this.projectActive()) {
|
|
398
478
|
const r = await this.project.read({ scope, key });
|
|
399
479
|
if (r.entry) return r;
|
|
@@ -401,6 +481,20 @@ class TwoTierStore {
|
|
|
401
481
|
return this.home.read({ scope, key });
|
|
402
482
|
}
|
|
403
483
|
|
|
484
|
+
// readMany(refs) → { ok, entries, missing } — per-ref `read()`, so the same
|
|
485
|
+
// project-shadows-home precedence applies to every ref individually rather
|
|
486
|
+
// than to the batch as a whole.
|
|
487
|
+
async readMany(refs = []) {
|
|
488
|
+
const entries = [];
|
|
489
|
+
const missing = [];
|
|
490
|
+
for (const { scope, key } of refs) {
|
|
491
|
+
const r = await this.read({ scope, key });
|
|
492
|
+
if (r.entry) entries.push(r.entry);
|
|
493
|
+
else missing.push(`${scope}::${key}`);
|
|
494
|
+
}
|
|
495
|
+
return { ok: true, entries, missing };
|
|
496
|
+
}
|
|
497
|
+
|
|
404
498
|
async write(args = {}) {
|
|
405
499
|
return this.tierFor(args.scope).write(args);
|
|
406
500
|
}
|
package/src/store/remote.mjs
CHANGED
|
@@ -22,8 +22,19 @@ import { restFetch, mcpToRestBase } from '../shared/mcp.mjs';
|
|
|
22
22
|
import { rememberAccountId } from '../telemetry/telemetry-identity.mjs';
|
|
23
23
|
import { getActiveTraceparent } from '../telemetry/telemetry.mjs';
|
|
24
24
|
import { withReadFields } from './entry-fields.mjs';
|
|
25
|
+
import { pickScopeWinner } from '../shared/scope-precedence.mjs';
|
|
25
26
|
import { normalizeCreatedAt } from './created-at.mjs';
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* How many same-key rows an UNSCOPED read fetches before picking a winner.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately the same number the hosted `memory.read` caps its own candidate
|
|
32
|
+
* fan-out at, so a key living in a pathological number of scopes truncates at
|
|
33
|
+
* the same point on both surfaces instead of the two considering different
|
|
34
|
+
* candidate sets and disagreeing about the winner.
|
|
35
|
+
*/
|
|
36
|
+
const UNSCOPED_READ_CANDIDATE_LIMIT = 50;
|
|
37
|
+
|
|
27
38
|
// Drop undefined/null args so JSON payloads stay tidy.
|
|
28
39
|
function stripUndefined(obj) {
|
|
29
40
|
const out = {};
|
|
@@ -249,12 +260,24 @@ class RemoteStore {
|
|
|
249
260
|
};
|
|
250
261
|
}
|
|
251
262
|
|
|
263
|
+
// read({ scope, key }) → { ok, entry } — `scope` is OPTIONAL.
|
|
264
|
+
//
|
|
265
|
+
// Without one the key can match once per scope holding it, so the precedence
|
|
266
|
+
// pick happens HERE rather than server-side: `GET /memories` is a LIST route,
|
|
267
|
+
// and narrowing it to a single winner would change what every other caller of
|
|
268
|
+
// it gets back. The rule is the shared `scope-precedence` one, so this
|
|
269
|
+
// resolves identically to MCP's `memory.read` (which picks server-side
|
|
270
|
+
// because it must answer with one lesson) and to the offline store.
|
|
252
271
|
async read({ scope, key } = {}) {
|
|
253
272
|
const p = new URLSearchParams();
|
|
254
273
|
if (scope) p.set('scope', scope);
|
|
255
274
|
if (key) p.set('key', key);
|
|
256
|
-
// scope
|
|
257
|
-
|
|
275
|
+
// With a scope, `scope`+`key` is unique and one row is all there can be —
|
|
276
|
+
// don't pull the default page of 50. Without one, fetch the candidates the
|
|
277
|
+
// precedence pick needs; the cap matches the hosted read's own so the two
|
|
278
|
+
// surfaces truncate a pathological key at the same point rather than
|
|
279
|
+
// disagreeing about which lessons were even considered.
|
|
280
|
+
p.set('limit', scope ? '1' : String(UNSCOPED_READ_CANDIDATE_LIMIT));
|
|
258
281
|
const res = await this._rest(`/memories?${p}`);
|
|
259
282
|
// `unusable` is passed through: `_rest` short-circuits an unconfigured
|
|
260
283
|
// store with that flag and NOTHING else, so a caller that drops it is left
|
|
@@ -275,9 +298,43 @@ class RemoteStore {
|
|
|
275
298
|
};
|
|
276
299
|
}
|
|
277
300
|
const entries = res.data?.entries ?? [];
|
|
301
|
+
// `key` is an EXACT filter server-side, so every row here carries the key
|
|
302
|
+
// that was asked for; precedence only has to choose among their scopes. A
|
|
303
|
+
// scoped read has at most one candidate, so the pick is a no-op there.
|
|
304
|
+
const winner = pickScopeWinner(entries);
|
|
278
305
|
// Same projection as list/search — a single read must not answer with a
|
|
279
306
|
// different shape than the listing the caller found the key in.
|
|
280
|
-
return { ok: true, entry:
|
|
307
|
+
return { ok: true, entry: winner ? withReadFields(winner) : null };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// readMany(refs) → { ok, entries, missing } — `POST /memories/read`, one
|
|
311
|
+
// round-trip for the whole batch (the server groups by scope and issues one
|
|
312
|
+
// `.in('key', …)` query per distinct scope — see `handlers/read.ts`). `refs`
|
|
313
|
+
// is an array of `{ scope, key }`; this transport joins each into the
|
|
314
|
+
// `scope::key` wire form the route's `ReadMemoriesBodySchema` expects. An
|
|
315
|
+
// empty/absent list short-circuits locally rather than making a request the
|
|
316
|
+
// server would just 400 on (`refs` has `.min(1)`).
|
|
317
|
+
async readMany(refs) {
|
|
318
|
+
const list = Array.isArray(refs) ? refs.filter(Boolean) : [];
|
|
319
|
+
if (!list.length) return { ok: true, entries: [], missing: [] };
|
|
320
|
+
const body = { refs: list.map((r) => `${r.scope}::${r.key}`) };
|
|
321
|
+
const res = await this._rest('/memories/read', { method: 'POST', body });
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: res.error ?? null,
|
|
326
|
+
httpStatus: res.httpStatus ?? null,
|
|
327
|
+
retryAfter: res.retryAfter ?? null,
|
|
328
|
+
networkError: res.networkError ?? null,
|
|
329
|
+
unusable: res.unusable ?? false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const data = res.data ?? {};
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
entries: (data.entries ?? []).map(withReadFields),
|
|
336
|
+
missing: data.missing ?? [],
|
|
337
|
+
};
|
|
281
338
|
}
|
|
282
339
|
|
|
283
340
|
async write(args = {}) {
|
|
@@ -160,37 +160,37 @@ export const MCP_TOOL_DEFS = [
|
|
|
160
160
|
},
|
|
161
161
|
{
|
|
162
162
|
"name": "memory.read",
|
|
163
|
-
"description": "Read
|
|
163
|
+
"description": "Read one lesson by `key`, or several at once by `refs`. Pass exactly one of those two shapes: `key` (optionally narrowed with `scope`), or `refs` alone — a call carrying both is rejected, and so is one carrying neither. `scope` is optional: omit it and the key is resolved across every scope you can see, preferring the most specific one.",
|
|
164
164
|
"inputSchema": {
|
|
165
165
|
"type": "object",
|
|
166
|
-
"required": [
|
|
167
|
-
"scope",
|
|
168
|
-
"key"
|
|
169
|
-
],
|
|
170
166
|
"properties": {
|
|
171
167
|
"scope": {
|
|
172
168
|
"type": "string",
|
|
173
|
-
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
169
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
|
|
174
170
|
},
|
|
175
171
|
"key": {
|
|
176
172
|
"type": "string",
|
|
177
173
|
"description": "Lesson identifier, unique within the scope. Max 512 characters."
|
|
174
|
+
},
|
|
175
|
+
"refs": {
|
|
176
|
+
"type": "array",
|
|
177
|
+
"items": {
|
|
178
|
+
"type": "string"
|
|
179
|
+
},
|
|
180
|
+
"description": "Batch mode: one or more `scope::key` references, fetched in a single call. Cannot be combined with `scope`/`key`. Each entry is parsed by the same reference grammar `memory.write`'s `cited` field uses (`scope::key`, verbatim scope — never lowercased). Silently truncated past 32 entries."
|
|
178
181
|
}
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
},
|
|
182
185
|
{
|
|
183
186
|
"name": "memory.list",
|
|
184
|
-
"description": "List lessons for
|
|
187
|
+
"description": "List lessons, for one scope or across every scope you can see",
|
|
185
188
|
"inputSchema": {
|
|
186
189
|
"type": "object",
|
|
187
|
-
"required": [
|
|
188
|
-
"scope"
|
|
189
|
-
],
|
|
190
190
|
"properties": {
|
|
191
191
|
"scope": {
|
|
192
192
|
"type": "string",
|
|
193
|
-
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
193
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
|
|
194
194
|
},
|
|
195
195
|
"tags": {
|
|
196
196
|
"type": "array",
|
|
@@ -338,7 +338,7 @@ export const MCP_TOOL_DEFS = [
|
|
|
338
338
|
},
|
|
339
339
|
{
|
|
340
340
|
"name": "memory.scopes",
|
|
341
|
-
"description": "List every scope in the store with how many active memories it holds and when it was last written to — the inventory to consult when you do not already know which scope to read. Takes no arguments and is store-wide, NOT limited to any working directory.
|
|
341
|
+
"description": "List every scope in the store with how many active memories it holds and when it was last written to — the inventory to consult when you do not already know which scope to read. Takes no arguments and is store-wide, NOT limited to any working directory. The other read tools answer questions about lore; this one answers \"what is there?\" — reach for it when you want to NAME a scope, to narrow a list or to decide where a write belongs.",
|
|
342
342
|
"inputSchema": {
|
|
343
343
|
"type": "object",
|
|
344
344
|
"properties": {}
|
|
@@ -346,16 +346,13 @@ export const MCP_TOOL_DEFS = [
|
|
|
346
346
|
},
|
|
347
347
|
{
|
|
348
348
|
"name": "memory.list_archived",
|
|
349
|
-
"description": "List archived (soft-deleted) lessons for
|
|
349
|
+
"description": "List archived (soft-deleted) lessons, for one scope or across every scope you can see",
|
|
350
350
|
"inputSchema": {
|
|
351
351
|
"type": "object",
|
|
352
|
-
"required": [
|
|
353
|
-
"scope"
|
|
354
|
-
],
|
|
355
352
|
"properties": {
|
|
356
353
|
"scope": {
|
|
357
354
|
"type": "string",
|
|
358
|
-
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
355
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
|
|
359
356
|
},
|
|
360
357
|
"limit": {
|
|
361
358
|
"type": "integer",
|