@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.55.2",
3
+ "version": "1.56.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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
@@ -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; the only way to name a key containing `::`
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; the escape hatch for a key
64
- // containing `::`
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: split at the LAST `::` and take it as `<scope>::<key>` ONLY when the
126
- // left side is itself a COMPLETE valid scope — otherwise the whole arg is the
127
- // scope. Splitting on the last `::` (not the first) keeps a multi-segment scope
128
- // whole (`repo::owner/name::key` scope `repo::owner/name`, key `key`); gating
129
- // on a valid left side means a bare `repo::owner/name` is NOT mis-split, because
130
- // its left part `repo` is not a valid scope. This is the fix for the prior
131
- // first-`::` split, which turned `link repo::acme/widget` into scope="repo" plus
132
- // a bogus `acme/widget` key — breaking the shorthand for EVERY non-`global`
133
- // scope. A malformed arg falls through to the scope, never a fabricated key.
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
- const idx = s.lastIndexOf('::');
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 };
@@ -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