@lorekit/cli 1.55.2 → 1.56.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 +54 -1
- package/bin/lorekit.mjs +49 -2
- package/package.json +1 -1
- package/src/commands/mcp-server.mjs +1 -1
- package/src/commands/obligations.mjs +135 -0
- package/src/commands/purge.mjs +1 -1
- package/src/commands/show.mjs +7 -3
- package/src/commands.mjs +2 -0
- package/src/shared/lessons-pure.mjs +17 -11
- package/src/shared/mcp.mjs +1 -1
- package/src/shared/mirror-pairs.mjs +86 -0
- package/src/shared/obligations-map.mjs +154 -0
- package/src/shared/obligations-pure.mjs +246 -0
- package/src/surfaces.generated.mjs +1 -1
- package/src/telemetry/telemetry-token.mjs +1 -1
- package/src/telemetry/telemetry.mjs +4 -4
package/README.md
CHANGED
|
@@ -443,6 +443,57 @@ coincidental overlaps. Any pair scoring at or above `--threshold` links (transit
|
|
|
443
443
|
into one cluster; only clusters of 2+ members are reported, each with a similarity
|
|
444
444
|
range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
|
|
445
445
|
|
|
446
|
+
### `lorekit obligations`
|
|
447
|
+
|
|
448
|
+
Check a changed-file set against the **Surface-Partner Map** — a declarative
|
|
449
|
+
registry of known, path-keyed file partnerships (a mirrored module, a doc that
|
|
450
|
+
copies a claim, a generated artifact) mined from existing CI guards — and flag
|
|
451
|
+
any partner the map says the changed-set owes but doesn't contain:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
lorekit obligations supabase/functions/_shared/audit/audit.ts # positionals
|
|
455
|
+
lorekit obligations --files packages/schemas/src/shared/tool-catalog.ts --json
|
|
456
|
+
git diff --name-only origin/main... | lorekit obligations --strict # from a real diff
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
This is a machine version of a recurring review finding: a fix to one surface
|
|
460
|
+
leaves its partner stale because the lessons documenting the partnership are
|
|
461
|
+
retrieved lexically (full-text search + recency) and rarely surface at edit
|
|
462
|
+
time for the exact file just touched. Each matched entry prints its obliged
|
|
463
|
+
partner files/actions, marks each as met (✓) or unmet (!), and cites the
|
|
464
|
+
memory `lessonKey` the partnership encodes.
|
|
465
|
+
|
|
466
|
+
An `obliges` element is a required partner path/glob, a `run:<action>`
|
|
467
|
+
advisory that is always reported but never gates `--strict` (some
|
|
468
|
+
partnerships are "regenerate this," not "edit this file"), or an "any of"
|
|
469
|
+
group satisfied by whichever of several candidates is present. `{name}` (or
|
|
470
|
+
`**/{name}`) in a `match`/`obliges` glob binds a mirrored module's relative
|
|
471
|
+
path (directories + stem, extension stripped), for the (rare) case where a
|
|
472
|
+
partner's path genuinely IS a predictable function of the source's, so one
|
|
473
|
+
entry covers every module instead of needing one per file.
|
|
474
|
+
|
|
475
|
+
The `edge-mirror`/`edge-mirror-core` entries (mcp-core ↔ the self-contained
|
|
476
|
+
Deno edge mirrors) do NOT use that glob mechanism: an edge mirror doesn't
|
|
477
|
+
reliably preserve mcp-core's directory structure (it may flatten or rename
|
|
478
|
+
it), so a symmetric-path reconstruction false-positives on exactly those
|
|
479
|
+
pairs. Instead, both entries are generated — one row per pair — from
|
|
480
|
+
`src/shared/mirror-pairs.mjs`, the single-source inventory
|
|
481
|
+
`packages/mcp-core/src/edge/edge-parity.spec.ts` also reads for its
|
|
482
|
+
byte-comparison drift guard, so the spec and this command can never disagree
|
|
483
|
+
about which files mirror which.
|
|
484
|
+
|
|
485
|
+
**Cwd-independent by design**: it matches the path STRINGS it is given
|
|
486
|
+
against the map and never reads the filesystem or resolves scope from the
|
|
487
|
+
current directory — the changed-set can come from a real `git diff`, a PR
|
|
488
|
+
file list, or by hand, from anywhere.
|
|
489
|
+
|
|
490
|
+
Exits 0 by default; `--strict` exits non-zero when any PATH obligation is
|
|
491
|
+
unmet. `--json` → `{ files, matched, unmet, ok }`. CLI-only (`native` — no MCP
|
|
492
|
+
tool, no REST route, no `tool-catalog.ts` entry): a path-matching lint utility
|
|
493
|
+
is not an operation surface. Slice 1 of a larger design — wiring a
|
|
494
|
+
`PreToolUse` hook to call this at edit time, and server-side retrieval
|
|
495
|
+
changes, are named follow-ups, not built here.
|
|
496
|
+
|
|
446
497
|
### `lorekit link` (alias `url`)
|
|
447
498
|
|
|
448
499
|
Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
|
|
@@ -996,12 +1047,14 @@ also returns their headroom against the plan's memory cap.
|
|
|
996
1047
|
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
997
1048
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
998
1049
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
999
|
-
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link` / `purge` / `purge-expired`) |
|
|
1050
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `link` / `purge` / `purge-expired`) |
|
|
1000
1051
|
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory. On `show` / `write` it **names** the scope, overriding the positional |
|
|
1001
1052
|
| `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
|
|
1002
1053
|
| `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
|
|
1003
1054
|
| `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
|
|
1004
1055
|
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
1056
|
+
| `--files <path>...` | Changed files to check (`obligations`); also accepted as positionals or newline-separated stdin |
|
|
1057
|
+
| `--strict` | Exit non-zero on any unmet obligation (`obligations`) |
|
|
1005
1058
|
| `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
|
|
1006
1059
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
1007
1060
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -74,6 +74,14 @@ ${c.bold('Commands')}
|
|
|
74
74
|
dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
|
|
75
75
|
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
76
76
|
store. --json, --scope <s>, --threshold <0..1>.
|
|
77
|
+
obligations Check a changed-file set against the Surface-Partner Map: known,
|
|
78
|
+
path-keyed file partnerships (a mirrored module, a doc that
|
|
79
|
+
copies a claim, a generated artifact) mined from existing CI
|
|
80
|
+
guards. Prints each matched partnership's obliged partner
|
|
81
|
+
files/actions and flags any partner NOT in the given set.
|
|
82
|
+
Cwd-independent — matches path strings, never reads the FS.
|
|
83
|
+
--files <path>..., positionals, or stdin (newline-separated).
|
|
84
|
+
--json, --strict (exit non-zero on any unmet obligation).
|
|
77
85
|
link (url) Print a shareable dashboard deep-link URL for the current context,
|
|
78
86
|
a scope, or a specific lesson (opens its detail sheet). No args
|
|
79
87
|
links to the cwd's most-specific scope. Filter flags mirror the
|
|
@@ -111,9 +119,11 @@ ${c.bold('Options')}
|
|
|
111
119
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
112
120
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
113
121
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
114
|
-
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
|
|
122
|
+
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / link)
|
|
115
123
|
--scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
|
|
116
124
|
On show / write it NAMES the scope, overriding the positional
|
|
125
|
+
--files <path>... Changed files to check (obligations); also accepted as positionals or newline-separated stdin
|
|
126
|
+
--strict Exit non-zero on any unmet obligation (obligations)
|
|
117
127
|
--key <key> Name the key explicitly (show / write / link) — the way to
|
|
118
128
|
address a key that itself contains \`::\`
|
|
119
129
|
--link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
|
|
@@ -566,6 +576,41 @@ ${c.bold('Examples')}
|
|
|
566
576
|
npx @lorekit/cli dedupe
|
|
567
577
|
npx @lorekit/cli dedupe --threshold 0.6 --json
|
|
568
578
|
npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
|
|
579
|
+
`,
|
|
580
|
+
obligations: `${c.bold('lorekit obligations')} — check a changed-file set against the Surface-Partner Map
|
|
581
|
+
|
|
582
|
+
${c.bold('Usage')}
|
|
583
|
+
npx @lorekit/cli obligations <path>... [options]
|
|
584
|
+
npx @lorekit/cli obligations --files <path>... [options]
|
|
585
|
+
git diff --name-only | npx @lorekit/cli obligations [options]
|
|
586
|
+
|
|
587
|
+
Checks a changed-file set against a declarative registry of known, path-keyed
|
|
588
|
+
file partnerships (a mirrored module, a doc that copies a claim, a generated
|
|
589
|
+
artifact) mined from existing CI guards — a machine version of the recurring
|
|
590
|
+
review finding "you fixed one surface and left its partner stale." For each
|
|
591
|
+
matched partnership it prints the obliged partner files/actions and flags any
|
|
592
|
+
partner NOT in the given changed-set, citing the memory lesson the
|
|
593
|
+
partnership encodes.
|
|
594
|
+
|
|
595
|
+
Cwd-INDEPENDENT: it matches the path STRINGS it is given against the map — it
|
|
596
|
+
never reads the filesystem or resolves scope from the current directory, so
|
|
597
|
+
the changed-set can come from anywhere (a git diff, a PR file list, by hand).
|
|
598
|
+
|
|
599
|
+
The changed-set is positionals unioned with ${c.cyan('--files')} (its single-value
|
|
600
|
+
form — extra paths after it fall through as positionals); when neither is
|
|
601
|
+
given, it falls back to stdin lines (newline-separated, trimmed, non-empty),
|
|
602
|
+
read only when stdin is piped.
|
|
603
|
+
|
|
604
|
+
${c.bold('Options')}
|
|
605
|
+
--files <path>... Changed files to check (also: positionals, stdin)
|
|
606
|
+
--strict Exit non-zero when any path obligation is unmet
|
|
607
|
+
(an advisory run: action never gates this)
|
|
608
|
+
--json Machine-readable output ({ files, matched, unmet, ok })
|
|
609
|
+
|
|
610
|
+
${c.bold('Examples')}
|
|
611
|
+
npx @lorekit/cli obligations supabase/functions/_shared/audit/audit.ts
|
|
612
|
+
npx @lorekit/cli obligations --files packages/schemas/src/shared/tool-catalog.ts --json
|
|
613
|
+
git diff --name-only origin/main... | npx @lorekit/cli obligations --strict
|
|
569
614
|
`,
|
|
570
615
|
link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
|
|
571
616
|
|
|
@@ -835,6 +880,8 @@ const KNOWN_FLAGS = [
|
|
|
835
880
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
836
881
|
// Scale-aware survey flags
|
|
837
882
|
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
883
|
+
// `obligations`
|
|
884
|
+
'files', 'strict',
|
|
838
885
|
];
|
|
839
886
|
|
|
840
887
|
async function main() {
|
|
@@ -847,7 +894,7 @@ async function main() {
|
|
|
847
894
|
const argv = process.argv.slice(2);
|
|
848
895
|
const args = parseArgs(argv, {
|
|
849
896
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
850
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
|
|
897
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'strict'],
|
|
851
898
|
known: KNOWN_FLAGS,
|
|
852
899
|
});
|
|
853
900
|
|
package/package.json
CHANGED
|
@@ -365,7 +365,7 @@ export function advertise(dispatch) {
|
|
|
365
365
|
if (!def) {
|
|
366
366
|
throw new Error(
|
|
367
367
|
`mcp-server dispatches "${name}", which the tool catalog does not declare. `
|
|
368
|
-
+ 'Add it to packages/schemas/src/shared/tool-catalog.ts (and regenerate: node scripts/gen-surfaces.mjs).',
|
|
368
|
+
+ 'Add it to packages/schemas/src/shared/tool-catalog.ts (and regenerate: node scripts/codegen/gen-surfaces.mjs).',
|
|
369
369
|
);
|
|
370
370
|
}
|
|
371
371
|
return def;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// `lorekit obligations` — check a changed-file set against the Surface-Partner
|
|
2
|
+
// Map (`../shared/obligations-map.mjs`) and print any partner surface a known
|
|
3
|
+
// partnership obliges that is NOT itself in the changed set ("you forgot to
|
|
4
|
+
// sweep X"), citing the memory lesson the partnership encodes.
|
|
5
|
+
//
|
|
6
|
+
// This is a machine version of a recurring `dash0-dev` review finding: a fix
|
|
7
|
+
// to one surface leaves its partner stale because the lessons documenting the
|
|
8
|
+
// partnership are retrieved lexically and rarely surface at edit time for the
|
|
9
|
+
// exact file just touched. Slice 1 is a standalone CLI check — no hook
|
|
10
|
+
// wiring, no server changes (see the plan's Out-of-scope section).
|
|
11
|
+
//
|
|
12
|
+
// Cwd-INDEPENDENT by design (`cli-bash-cwd-resets-to-repo-root-each-call`):
|
|
13
|
+
// it matches the path STRINGS it is given against the map; it never reads the
|
|
14
|
+
// filesystem or resolves scope from the current directory, so it works the
|
|
15
|
+
// same regardless of where it is invoked from as long as the paths given are
|
|
16
|
+
// repo-relative.
|
|
17
|
+
//
|
|
18
|
+
// Changed-set resolution — positionals and `--files` are UNIONED (so both a
|
|
19
|
+
// bare list and the flag form work together, and `--files a b c` composes
|
|
20
|
+
// naturally: the parser's single-value form takes `a` as the flag's value and
|
|
21
|
+
// leaves `b`/`c` as positionals); stdin is read only as a FALLBACK, when
|
|
22
|
+
// NEITHER produced anything — the same flag → positional → stdin precedence
|
|
23
|
+
// `write.mjs`'s value resolution uses. This is a deliberate narrowing from a
|
|
24
|
+
// flat three-way union: reading stdin unconditionally means every invocation
|
|
25
|
+
// that already named its files explicitly still blocks on stdin closing,
|
|
26
|
+
// which is surprising for a caller that piped nothing, and turns any
|
|
27
|
+
// in-process call (e.g. this command under `node:test`, invoked directly
|
|
28
|
+
// rather than spawned) into a hang, since the test runner's own stdin never
|
|
29
|
+
// reaches EOF. De-duplicated, first-seen order preserved.
|
|
30
|
+
//
|
|
31
|
+
// 1. positionals after the command token (`obligations <path> <path> …`)
|
|
32
|
+
// unioned with `--files <path>`
|
|
33
|
+
// 2. stdin lines (trimmed, non-empty) — read ONLY when (1) is empty and
|
|
34
|
+
// stdin is not a TTY
|
|
35
|
+
//
|
|
36
|
+
// Exit code: 0 by default; 1 when `--strict` is given AND any path obligation
|
|
37
|
+
// is unmet. `run:` obliges are advisory (`met: null`) and never gate.
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
import { log, heading, c } from '../shared/util.mjs';
|
|
40
|
+
import { checkObligations } from '../shared/obligations-pure.mjs';
|
|
41
|
+
import { SURFACE_PARTNER_MAP } from '../shared/obligations-map.mjs';
|
|
42
|
+
|
|
43
|
+
// Read stdin line-by-line, trimmed, non-empty. Resolves to [] when stdin IS a
|
|
44
|
+
// TTY (no pipe) — the same "no pipe, no read" convention `write.mjs` uses.
|
|
45
|
+
function readStdinLines() {
|
|
46
|
+
if (process.stdin.isTTY) return Promise.resolve([]);
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
process.stdin.on('data', (d) => chunks.push(d));
|
|
50
|
+
process.stdin.on('end', () => {
|
|
51
|
+
const lines = Buffer.concat(chunks)
|
|
52
|
+
.toString('utf8')
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((l) => l.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
resolve(lines);
|
|
57
|
+
});
|
|
58
|
+
process.stdin.resume();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The resolved changed-set: (positionals ∪ --files), falling back to stdin
|
|
63
|
+
// only when that union is empty. De-duplicated, first-seen order preserved.
|
|
64
|
+
async function resolveChangedFiles(args) {
|
|
65
|
+
const positionals = args._.slice(1).filter((p) => typeof p === 'string' && p);
|
|
66
|
+
const flagged = typeof args.files === 'string' && args.files ? [args.files] : [];
|
|
67
|
+
const named = dedupe([...positionals, ...flagged]);
|
|
68
|
+
if (named.length > 0) return named;
|
|
69
|
+
return dedupe(await readStdinLines());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dedupe(list) {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const f of list) {
|
|
76
|
+
if (!seen.has(f)) {
|
|
77
|
+
seen.add(f);
|
|
78
|
+
out.push(f);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function obligations(args) {
|
|
85
|
+
const changedFiles = await resolveChangedFiles(args);
|
|
86
|
+
const strict = Boolean(args.strict);
|
|
87
|
+
const result = checkObligations({ changedFiles, map: SURFACE_PARTNER_MAP });
|
|
88
|
+
|
|
89
|
+
if (args.json) {
|
|
90
|
+
log(JSON.stringify({ ...result, strict }, null, 2));
|
|
91
|
+
} else {
|
|
92
|
+
render(result, changedFiles);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
exitCode: strict && result.unmet > 0 ? 1 : 0,
|
|
97
|
+
'lorekit.cli.obligations.files': changedFiles.length,
|
|
98
|
+
'lorekit.cli.obligations.matched': result.matched.length,
|
|
99
|
+
'lorekit.cli.obligations.unmet': result.unmet,
|
|
100
|
+
'lorekit.cli.obligations.strict': strict,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function render(result, changedFiles) {
|
|
105
|
+
heading('LoreKit obligations');
|
|
106
|
+
log(` files: ${c.dim(changedFiles.length ? changedFiles.join(', ') : '(none given)')}`);
|
|
107
|
+
|
|
108
|
+
if (result.matched.length === 0) {
|
|
109
|
+
log('');
|
|
110
|
+
log(` ${c.dim('no known surface-partner obligations for the given changed-set')}`);
|
|
111
|
+
log('');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const entry of result.matched) {
|
|
116
|
+
log('');
|
|
117
|
+
log(` ${c.bold(entry.id)}${entry.guard ? c.dim(` (guard: ${entry.guard})`) : ''}`);
|
|
118
|
+
if (entry.note) log(` ${c.dim(entry.note)}`);
|
|
119
|
+
for (const o of entry.obliges) {
|
|
120
|
+
const mark = o.kind === 'action' ? c.cyan('•') : o.met ? c.green('✓') : c.yellow('!');
|
|
121
|
+
const suffix = o.kind === 'action' ? c.dim(' (advisory — run this yourself)') : '';
|
|
122
|
+
log(` ${mark} ${o.target}${suffix}`);
|
|
123
|
+
}
|
|
124
|
+
log(` ${c.dim(`cites: ${entry.lessonKey}`)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log('');
|
|
128
|
+
if (result.unmet === 0) {
|
|
129
|
+
log(` ${c.green('✓')} every known path obligation is satisfied by the given changed-set`);
|
|
130
|
+
} else {
|
|
131
|
+
const plural = result.unmet === 1 ? '' : 's';
|
|
132
|
+
log(` ${c.yellow('!')} ${result.unmet} unmet obligation${plural} — sweep the partner${plural} above`);
|
|
133
|
+
}
|
|
134
|
+
log('');
|
|
135
|
+
}
|
package/src/commands/purge.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// `--json`). An agent loop must not be able to trigger one by omission.
|
|
20
20
|
//
|
|
21
21
|
// 3. A SCOPED KEY IS REFUSED BY THE SERVER, and that refusal is passed through
|
|
22
|
-
// verbatim. `_shared/account-wide-tools.ts` refuses these two operations for
|
|
22
|
+
// verbatim. `_shared/auth/account-wide-tools.ts` refuses these two operations for
|
|
23
23
|
// any token carrying a scope allowlist — a key narrowed to one repo has no
|
|
24
24
|
// business sweeping the whole account. The CLI makes exactly one request and
|
|
25
25
|
// reports the server's answer; it never retries, never splits the sweep and
|
package/src/commands/show.mjs
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// you can copy-paste a key directly from list output)
|
|
11
11
|
// show <scope> <key> — the explicit two-positional form
|
|
12
12
|
// show --scope <s> --key <k>
|
|
13
|
-
// — flags win;
|
|
13
|
+
// — flags win; an explicit override that skips the `::`
|
|
14
|
+
// split (the shorthand already carries a namespaced
|
|
15
|
+
// key, since the split lands at the first valid-scope
|
|
16
|
+
// prefix — see `resolveScopeArg`)
|
|
14
17
|
//
|
|
15
18
|
// Uses each store's real `read({scope, key})` method (both stores expose it),
|
|
16
19
|
// not a filtered `list` — a single-record lookup is what `read` is for, and it
|
|
@@ -60,8 +63,9 @@ export async function show(args) {
|
|
|
60
63
|
// Positional shapes (all resolved by the shared, validity-gated parser):
|
|
61
64
|
// show <scope::key> — canonical shorthand, mirrors `list` output
|
|
62
65
|
// show <scope> <key> — explicit two-positional form
|
|
63
|
-
// show --scope <s> --key <k> — flags win;
|
|
64
|
-
//
|
|
66
|
+
// show --scope <s> --key <k> — flags win; an explicit override that skips
|
|
67
|
+
// the `::` split (the shorthand handles a
|
|
68
|
+
// namespaced key on its own now)
|
|
65
69
|
const positionals = args._.slice(1);
|
|
66
70
|
const { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
|
|
67
71
|
scope: args.scope,
|
package/src/commands.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { diff } from './commands/diff.mjs';
|
|
|
47
47
|
import { tree } from './commands/tree.mjs';
|
|
48
48
|
import { lint } from './commands/lint.mjs';
|
|
49
49
|
import { dedupe } from './commands/dedupe.mjs';
|
|
50
|
+
import { obligations } from './commands/obligations.mjs';
|
|
50
51
|
import { link } from './commands/link.mjs';
|
|
51
52
|
import { hook } from './commands/hook.mjs';
|
|
52
53
|
import { migrate } from './commands/migrate.mjs';
|
|
@@ -77,6 +78,7 @@ export const COMMANDS = [
|
|
|
77
78
|
{ name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] },
|
|
78
79
|
{ name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' },
|
|
79
80
|
{ name: 'dedupe', run: dedupe, traced: true, strictFlags: true, native: 'near-duplicate detection across a scope' },
|
|
81
|
+
{ name: 'obligations', run: obligations, traced: true, strictFlags: true, native: 'checks changed files against the surface-partner map' },
|
|
80
82
|
{ name: 'link', run: link, traced: true, strictFlags: true, native: 'builds a dashboard deep link', aliases: ['url'] },
|
|
81
83
|
{ name: 'migrate', run: migrate, traced: true, strictFlags: true, native: 'moves lore between local and remote stores' },
|
|
82
84
|
{ name: 'bootstrap', run: bootstrap, traced: true, strictFlags: true, native: 'seeds a fresh store from a template' },
|
|
@@ -122,23 +122,29 @@ export function isScopeString(s) {
|
|
|
122
122
|
// Split a single `<scope>::<key>` argument, or fall back to treating the whole
|
|
123
123
|
// argument as a scope.
|
|
124
124
|
//
|
|
125
|
-
// The rule:
|
|
126
|
-
// left side is
|
|
127
|
-
// scope.
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
125
|
+
// The rule: scan the `::` boundaries left-to-right and split at the FIRST one
|
|
126
|
+
// whose left side is a COMPLETE valid scope — otherwise the whole arg is the
|
|
127
|
+
// scope. Because `::` is RESERVED as the segment separator (no scope segment may
|
|
128
|
+
// contain it, enforced by `scopeIssue`), no valid scope is a `::`-boundary
|
|
129
|
+
// prefix of another, so the earliest valid-scope prefix is unambiguously THE
|
|
130
|
+
// scope and everything after it is the key — even a key that itself contains
|
|
131
|
+
// `::` (a namespaced key like `implement-suggestion-lessons::documenting-…`).
|
|
132
|
+
//
|
|
133
|
+
// This first-valid scan superseded a plain last-`::` split, which broke exactly
|
|
134
|
+
// that case: `global::foo-lessons::bar` split at the last `::` gave the left
|
|
135
|
+
// side `global::foo-lessons`, not a valid scope, so the whole arg fell through
|
|
136
|
+
// to the scope and `scopeIssue` rejected it. Gating on a valid left side is what
|
|
137
|
+
// keeps a bare `repo::owner/name` from mis-splitting (its `repo` prefix is not a
|
|
138
|
+
// valid scope) and a multi-segment scope whole (`branch::o/n::main::key` → scope
|
|
139
|
+
// `branch::o/n::main`, key `key`, since the shorter prefixes are all invalid). A
|
|
140
|
+
// malformed arg falls through to the scope, never a fabricated key.
|
|
134
141
|
//
|
|
135
142
|
// `isScope` is injected rather than closed over so the module stays trivially
|
|
136
143
|
// testable with a stub predicate; callers pass `isScopeString`.
|
|
137
144
|
export function resolveScopeArg(arg, isScope = isScopeString) {
|
|
138
145
|
const s = typeof arg === 'string' ? arg.trim() : '';
|
|
139
146
|
if (!s) return { scope: null, key: null };
|
|
140
|
-
|
|
141
|
-
if (idx !== -1) {
|
|
147
|
+
for (let idx = s.indexOf('::'); idx !== -1; idx = s.indexOf('::', idx + 2)) {
|
|
142
148
|
const left = s.slice(0, idx).trim();
|
|
143
149
|
const right = s.slice(idx + 2).trim();
|
|
144
150
|
if (right && isScope(left)) return { scope: left, key: right };
|
package/src/shared/mcp.mjs
CHANGED
|
@@ -159,7 +159,7 @@ export function mcpToRestBase(mcpEndpointUrl) {
|
|
|
159
159
|
/**
|
|
160
160
|
* Normalise a client-supplied usage correlation id (a PR ref, session id, or CI
|
|
161
161
|
* job id). Bounded + charset-restricted to match the server's `parseCorrelationId`
|
|
162
|
-
* (supabase/functions/_shared/usage-stats.ts); returns null for empty/over-long/
|
|
162
|
+
* (supabase/functions/_shared/telemetry/usage-stats.ts); returns null for empty/over-long/
|
|
163
163
|
* out-of-charset input so a bad value is simply not sent. Zero-dep (the CLI does
|
|
164
164
|
* not import mcp-core), so the small regex is duplicated intentionally.
|
|
165
165
|
*/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The single-source inventory of known mcp-core ↔ edge (Deno) mirror pairs.
|
|
2
|
+
//
|
|
3
|
+
// TWO consumers read this ONE list instead of each keeping (or reconstructing)
|
|
4
|
+
// its own:
|
|
5
|
+
// - `packages/mcp-core/src/edge/edge-parity.spec.ts` — loads it by URL (the
|
|
6
|
+
// `lesson-rank-parity.spec.ts` cross-runtime pattern: this is a plain
|
|
7
|
+
// `.mjs` package outside the vitest project's tsconfig) and runs its
|
|
8
|
+
// byte-for-byte drift guard over every `driftChecked: true` pair.
|
|
9
|
+
// - `packages/cli/src/shared/obligations-map.mjs` — generates the
|
|
10
|
+
// `edge-mirror` / `edge-mirror-core` Surface-Partner Map rows from EVERY
|
|
11
|
+
// pair here (`driftChecked` included), so `lorekit obligations` reports
|
|
12
|
+
// the pair's ACTUAL partner.
|
|
13
|
+
//
|
|
14
|
+
// Why a flat enumerated list rather than a glob + `{name}` substitution: an
|
|
15
|
+
// edge mirror does not always preserve mcp-core's directory structure — e.g.
|
|
16
|
+
// `packages/mcp-core/src/auth/auth-token.ts` mirrors the FLAT
|
|
17
|
+
// `supabase/functions/mcp/auth-token.ts`, not
|
|
18
|
+
// `supabase/functions/mcp/auth/auth-token.ts`. A glob that assumes the same
|
|
19
|
+
// relative subpath on both sides reconstructs the WRONG partner path for
|
|
20
|
+
// every such flatten/rename and reports a real, present mirror as
|
|
21
|
+
// chronically unmet. Enumerating each pair's exact core/edge path from one
|
|
22
|
+
// inventory has no such assumption to violate.
|
|
23
|
+
//
|
|
24
|
+
// Every path is REPO-RELATIVE (not relative to this file), so both consumers
|
|
25
|
+
// use it directly: `edge-parity.spec.ts` joins it against the repo root,
|
|
26
|
+
// and `obligations-map.mjs`/`checkObligations` match it against changed-file
|
|
27
|
+
// path strings verbatim.
|
|
28
|
+
//
|
|
29
|
+
// `driftChecked: true` — both files are import-free, so `edge-parity.spec.ts`
|
|
30
|
+
// also runs its whole-file byte comparison over the
|
|
31
|
+
// pair (stripped of comments/blank lines).
|
|
32
|
+
// `driftChecked: false` — a REAL partner for `lorekit obligations` purposes,
|
|
33
|
+
// but excluded from that byte comparison because the
|
|
34
|
+
// edge copy is not import-free (it carries Deno-only
|
|
35
|
+
// types/APIs the mcp-core copy has no counterpart
|
|
36
|
+
// for — see each entry's note below).
|
|
37
|
+
//
|
|
38
|
+
// Deliberately NOT included: `supabase/functions/mcp/cursor.ts` ↔
|
|
39
|
+
// `supabase/functions/_shared/api/paginate.ts` (guarded by edge-parity.spec.ts's
|
|
40
|
+
// separate "cursor mirror parity" block). Both sides live under
|
|
41
|
+
// `supabase/functions/`, so it is an edge↔edge mirror, not the mcp-core↔edge
|
|
42
|
+
// partnership this inventory and the `edge-mirror`/`edge-mirror-core` map
|
|
43
|
+
// entries model.
|
|
44
|
+
export const mirrorPairs = [
|
|
45
|
+
{ core: 'packages/mcp-core/src/auth/auth-token.ts', edge: 'supabase/functions/mcp/auth-token.ts', driftChecked: true },
|
|
46
|
+
{ core: 'packages/mcp-core/src/limits/created-at.ts', edge: 'supabase/functions/_shared/limits/created-at.ts', driftChecked: true },
|
|
47
|
+
{ core: 'packages/mcp-core/src/limits/ttl.ts', edge: 'supabase/functions/mcp/ttl.ts', driftChecked: true },
|
|
48
|
+
{ core: 'packages/mcp-core/src/limits/ttl-defaults.ts', edge: 'supabase/functions/mcp/ttl-defaults.ts', driftChecked: true },
|
|
49
|
+
{ core: 'packages/mcp-core/src/provenance/origin.ts', edge: 'supabase/functions/_shared/provenance/origin.ts', driftChecked: true },
|
|
50
|
+
{ core: 'packages/mcp-core/src/webhook/webhook-secret-select.ts', edge: 'supabase/functions/mcp/webhook-secret-select.ts', driftChecked: true },
|
|
51
|
+
{ core: 'packages/mcp-core/src/auth/tenant-scope.ts', edge: 'supabase/functions/_shared/auth/tenant-scope.ts', driftChecked: true },
|
|
52
|
+
{ core: 'packages/mcp-core/src/auth/org-permissions.ts', edge: 'supabase/functions/mcp/org-permissions.ts', driftChecked: true },
|
|
53
|
+
{ core: 'packages/mcp-core/src/webhook/webhook-installation.ts', edge: 'supabase/functions/mcp/webhook-installation.ts', driftChecked: true },
|
|
54
|
+
{ core: 'packages/mcp-core/src/webhook/github-app-jwt.ts', edge: 'supabase/functions/mcp/github-app-jwt.ts', driftChecked: true },
|
|
55
|
+
{ core: 'packages/mcp-core/src/telemetry/trace-context.ts', edge: 'supabase/functions/_shared/telemetry/trace-context.ts', driftChecked: true },
|
|
56
|
+
{ core: 'packages/mcp-core/src/rest/rest-tool-name.ts', edge: 'supabase/functions/_shared/rest/rest-tool-name.ts', driftChecked: true },
|
|
57
|
+
// Has a SECOND, cross-LANGUAGE twin no byte comparison can cover — the
|
|
58
|
+
// CLI's own `lessons-pure.mjs` — guarded behaviourally by
|
|
59
|
+
// `lesson-rank-parity.spec.ts` instead.
|
|
60
|
+
{ core: 'packages/mcp-core/src/ranking/lesson-rank.ts', edge: 'supabase/functions/_shared/ranking/lesson-rank.ts', driftChecked: true },
|
|
61
|
+
{ core: 'packages/mcp-core/src/ranking/outcome-signal.ts', edge: 'supabase/functions/_shared/ranking/outcome-signal.ts', driftChecked: true },
|
|
62
|
+
{ core: 'packages/mcp-core/src/provenance/embedding.ts', edge: 'supabase/functions/_shared/embedding/embedding.ts', driftChecked: true },
|
|
63
|
+
{ core: 'packages/mcp-core/src/audit/rest-audit-actor.ts', edge: 'supabase/functions/_shared/audit/rest-audit-actor.ts', driftChecked: true },
|
|
64
|
+
{ core: 'packages/mcp-core/src/rest/rest-response-outcome.ts', edge: 'supabase/functions/_shared/rest/rest-response-outcome.ts', driftChecked: true },
|
|
65
|
+
{ core: 'packages/mcp-core/src/limits/dry-run.ts', edge: 'supabase/functions/_shared/limits/dry-run.ts', driftChecked: true },
|
|
66
|
+
{ core: 'packages/mcp-core/src/telemetry/usage-stats.ts', edge: 'supabase/functions/_shared/telemetry/usage-stats.ts', driftChecked: true },
|
|
67
|
+
{ core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true },
|
|
68
|
+
{ core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true },
|
|
69
|
+
{ core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true },
|
|
70
|
+
{ core: 'packages/mcp-core/src/auth/account-wide-tools.ts', edge: 'supabase/functions/_shared/auth/account-wide-tools.ts', driftChecked: true },
|
|
71
|
+
{ core: 'packages/mcp-core/src/telemetry/io-ledger.ts', edge: 'supabase/functions/_shared/telemetry/io-ledger.ts', driftChecked: true },
|
|
72
|
+
{ core: 'packages/mcp-core/src/telemetry/db-query-metrics.ts', edge: 'supabase/functions/_shared/telemetry/db-query-metrics.ts', driftChecked: true },
|
|
73
|
+
// Excluded from the byte-comparison drift check: the edge copy types the
|
|
74
|
+
// client as `ReturnType<typeof createClient>` off an `npm:` specifier where
|
|
75
|
+
// mcp-core imports a typed `SupabaseClient`, and additionally carries
|
|
76
|
+
// `recordAuditDeferred` (a Deno-only `EdgeRuntime.waitUntil` API with no
|
|
77
|
+
// mcp-core counterpart), so a whole-file comparison does not apply. Still a
|
|
78
|
+
// real partner — this is the AC-1 example.
|
|
79
|
+
{ core: 'packages/mcp-core/src/audit/audit.ts', edge: 'supabase/functions/_shared/audit/audit.ts', driftChecked: false },
|
|
80
|
+
// Excluded from the byte-comparison drift check for the same reason as
|
|
81
|
+
// `limits.ts` generally (see edge-parity.spec.ts): the edge copy pulls in
|
|
82
|
+
// Deno-specific imports, so a whole-file source comparison does not apply.
|
|
83
|
+
// Its shared pure logic is exercised behaviourally by `limits.spec.ts` on
|
|
84
|
+
// the mcp-core copy. Still a real partner.
|
|
85
|
+
{ core: 'packages/mcp-core/src/limits/limits.ts', edge: 'supabase/functions/mcp/limits.ts', driftChecked: false },
|
|
86
|
+
];
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED — do not edit.
|
|
2
2
|
// Source: packages/schemas/src/shared/tool-catalog.ts
|
|
3
|
-
// Regenerate: node scripts/gen-surfaces.mjs
|
|
3
|
+
// Regenerate: node scripts/codegen/gen-surfaces.mjs
|
|
4
4
|
//
|
|
5
5
|
// Edit the catalog's `surfaces` bindings, not this file. `--check` fails CI
|
|
6
6
|
// when the two disagree.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// source tree and nothing secret is ever committed to git. The release workflow
|
|
5
5
|
// (.github/workflows/release.yml → publish-cli) overwrites this file at publish
|
|
6
6
|
// time from the LOREKIT_TELEMETRY_TOKEN secret via
|
|
7
|
-
// scripts/inject-telemetry-token.mjs, so only the *published npm tarball*
|
|
7
|
+
// scripts/telemetry/inject-telemetry-token.mjs, so only the *published npm tarball*
|
|
8
8
|
// carries the token.
|
|
9
9
|
//
|
|
10
10
|
// The token is public by design once published (anyone can unpack the tarball),
|
|
@@ -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:
|
|
@@ -183,7 +183,7 @@ export function resolveTelemetryTokenSource(env = process.env) {
|
|
|
183
183
|
return 'none';
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// ── ID + value helpers (mirror _shared/otel.ts) ───────────────────────────────
|
|
186
|
+
// ── ID + value helpers (mirror _shared/telemetry/otel.ts) ───────────────────────────────
|
|
187
187
|
|
|
188
188
|
export function randHex(bytes) {
|
|
189
189
|
const b = new Uint8Array(bytes);
|
|
@@ -236,7 +236,7 @@ export function normalizeHostArch(arch) {
|
|
|
236
236
|
* the attribute by default. It is emitted ONLY when explicitly overridden via
|
|
237
237
|
* `DEPLOYMENT_ENVIRONMENT` (falling back to `OTEL_DEPLOYMENT_ENVIRONMENT`) — the
|
|
238
238
|
* same single, env-driven knob the edge honours, which the correlated-trace
|
|
239
|
-
* harness (`scripts/emit-correlated-trace.mts`) uses to stamp `test`.
|
|
239
|
+
* harness (`scripts/telemetry/emit-correlated-trace.mts`) uses to stamp `test`.
|
|
240
240
|
* @param {object} [env] defaults to process.env
|
|
241
241
|
*/
|
|
242
242
|
export function resolveDeploymentEnvironment(env = process.env) {
|
|
@@ -709,7 +709,7 @@ export async function meterCommand(command, version, run) {
|
|
|
709
709
|
* `lint` finding) reports `lorekit.cli.outcome=failure` on a span the exporter
|
|
710
710
|
* emits as STATUS_CODE_OK — never ERROR.
|
|
711
711
|
*
|
|
712
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
|
|
712
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | obligations | link | migrate
|
|
713
713
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
714
714
|
* @param {string} version CLI version (from package.json)
|
|
715
715
|
* @param {() => Promise<number>} run the command handler
|