@lorekit/cli 1.59.1 → 1.61.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
@@ -487,13 +487,62 @@ against the map and never reads the filesystem or resolves scope from the
487
487
  current directory — the changed-set can come from a real `git diff`, a PR
488
488
  file list, or by hand, from anywhere.
489
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
490
+ Each entry declares a `state`: `advisory` is reported but gates nothing,
491
+ `gating` fails `--strict`, `retired` is not reported. An entry may only be
492
+ `gating` if it has a `guard` — something independent of this map that already
493
+ asserts the partnership — so the two guard-less entries (`perf-index`,
494
+ `error-code-doc`) stay advisory by construction, enforced in the tests. Entries
495
+ also name the recurrence CLASS they instantiate rather than a bare lesson key,
496
+ so obligations sharing a root cause read as one problem.
497
+
498
+ Exits 0 by default; `--strict` exits non-zero when a GATING entry's PATH
499
+ obligation is unmet, `--strict-all` on ANY unmet obligation regardless of
500
+ state. `--json` → `{ files, matched, unmet, unmetGating, ok, okGating }`. CLI-only (`native` — no MCP
492
501
  tool, no REST route, no `tool-catalog.ts` entry): a path-matching lint utility
493
502
  is not an operation surface. Slice 1 of a larger design — wiring a
494
503
  `PreToolUse` hook to call this at edit time, and server-side retrieval
495
504
  changes, are named follow-ups, not built here.
496
505
 
506
+ ### `lorekit invariants candidates`
507
+
508
+ The compile pipeline's candidate scan — `cluster → groom-merge → compile
509
+ candidate → invariant`. A **read-only** survey that reuses `dedupe`'s Jaccard
510
+ clustering over the memory store, then ranks the clusters worth compiling
511
+ into a hand-written `obligations-map.mjs` entry:
512
+
513
+ ```bash
514
+ lorekit invariants candidates
515
+ lorekit invariants candidates --min-seen-count 5 --json
516
+ lorekit invariants candidates --scope repo::owner/repo
517
+ ```
518
+
519
+ A cluster is a candidate when the **summed `seen_count`** across its members
520
+ is at least `--min-seen-count` (default `3`), or any member's own
521
+ `<!-- meta: seen_count=… status=… trigger-context="…" -->` comment (the
522
+ convention documented in the `lorekit-setup` skill's
523
+ `self-improvement-loops.md`) already declares a non-`"active"` status.
524
+ Candidates are ranked by (summed `seen_count` × distinct scopes), descending.
525
+ **For each candidate it prints every memory the merge would collapse** —
526
+ that list is the whole point of the command.
527
+
528
+ It reuses the same `dedupe` ↔ `recurrence-clusters.mjs` join, so a candidate
529
+ whose members already resolve to a named recurrence class is flagged as a
530
+ stronger case ("this should join an existing invariant") than a merely
531
+ similar one ("this might be a new class").
532
+
533
+ It deliberately does **not** classify a `trigger-context` into a
534
+ glob/command/error-shape — that judgment is the human step the compile
535
+ pipeline's "never auto-compile, never auto-gate" rule protects, so the raw
536
+ string is printed, never interpreted — and it does **not** know about
537
+ `compiled_to` (no such field exists yet, so an already-compiled candidate can
538
+ still surface here — a known, named gap, not a silent omission).
539
+
540
+ Offline + Remote split like `dedupe`, with the same population cap. `--json`
541
+ → `{ root, scopes, minSeenCount, offline, remote }`; each candidate carries
542
+ `members` (`scope`, `key`, `seenCount`, `meta`), `score`, and
543
+ `recurrenceClass`. CLI-only (`native` — no MCP tool, no REST route, no
544
+ `tool-catalog.ts` entry), matching how `obligations` is registered.
545
+
497
546
  ### `lorekit link` (alias `url`)
498
547
 
499
548
  Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
@@ -1047,14 +1096,16 @@ also returns their headroom against the plan's memory cap.
1047
1096
  | `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
1048
1097
  | `--force` | Overwrite existing skill files (`install`) |
1049
1098
  | `--deep` | Write/read/delete round-trip (`doctor`) |
1050
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `link` / `purge` / `purge-expired`) |
1099
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `invariants candidates` / `link` / `purge` / `purge-expired`) |
1051
1100
  | `--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 |
1052
1101
  | `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
1053
1102
  | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
1054
1103
  | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
1055
1104
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
1056
1105
  | `--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`) |
1106
+ | `--strict` | Exit non-zero on an unmet obligation from a `gating` entry (`obligations`) |
1107
+ | `--strict-all` | Exit non-zero on ANY unmet obligation, advisory entries included (`obligations`) |
1108
+ | `--min-seen-count <n>` | Minimum summed `seen_count` for a cluster to be a candidate (`invariants candidates`; default `3`) |
1058
1109
  | `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
1059
1110
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
1060
1111
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
package/bin/lorekit.mjs CHANGED
@@ -81,7 +81,12 @@ ${c.bold('Commands')}
81
81
  files/actions and flags any partner NOT in the given set.
82
82
  Cwd-independent — matches path strings, never reads the FS.
83
83
  --files <path>..., positionals, or stdin (newline-separated).
84
- --json, --strict (exit non-zero on any unmet obligation).
84
+ --json, --strict (exit non-zero on an unmet obligation from a gating entry).
85
+ invariants \`invariants candidates\`: a read-only compile-pipeline scan —
86
+ reuses dedupe's clustering to rank near-duplicate memories as
87
+ merge candidates for a hand-written obligations-map.mjs entry.
88
+ Never auto-compiles or gates anything; prints every memory a
89
+ candidate would collapse. --json, --scope <s>, --min-seen-count <n>.
85
90
  link (url) Print a shareable dashboard deep-link URL for the current context,
86
91
  a scope, or a specific lesson (opens its detail sheet). No args
87
92
  links to the cwd's most-specific scope. Filter flags mirror the
@@ -135,11 +140,13 @@ ${c.bold('Options')}
135
140
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
136
141
  --mode <mode> Memory mode: off | local | remote (doctor override)
137
142
  --store <path> Local project-tier store directory (default: .lorekit)
138
- --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / link)
143
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / invariants / link)
144
+ --min-seen-count <n> Minimum summed seen_count for a cluster to be a candidate (invariants candidates; default 3)
139
145
  --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
140
146
  On show / write it NAMES the scope, overriding the positional
141
147
  --files <path>... Changed files to check (obligations); also accepted as positionals or newline-separated stdin
142
- --strict Exit non-zero on any unmet obligation (obligations)
148
+ --strict Exit non-zero on an unmet obligation from a gating entry (obligations)
149
+ --strict-all Exit non-zero on ANY unmet obligation, advisory entries included (obligations)
143
150
  --key <key> Name the key explicitly (show / write / link) — the way to
144
151
  address a key that itself contains \`::\`
145
152
  --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
@@ -633,6 +640,39 @@ ${c.bold('Examples')}
633
640
  npx @lorekit/cli obligations supabase/functions/_shared/audit/audit.ts
634
641
  npx @lorekit/cli obligations --files packages/schemas/src/shared/tool-catalog.ts --json
635
642
  git diff --name-only origin/main... | npx @lorekit/cli obligations --strict
643
+ `,
644
+ invariants: `${c.bold('lorekit invariants candidates')} — the compile pipeline's candidate scan
645
+
646
+ ${c.bold('Usage')}
647
+ npx @lorekit/cli invariants candidates [options]
648
+
649
+ A read-only survey that reuses dedupe's Jaccard clustering to find
650
+ near-duplicate memories, then ranks the clusters worth compiling into a
651
+ hand-written obligations-map.mjs entry. It never auto-compiles and never
652
+ gates anything — it prints candidates for a HUMAN to review. For each
653
+ candidate it prints every memory the merge would collapse, which is the
654
+ whole point of the command.
655
+
656
+ A cluster is a candidate when the summed seen_count across its members is at
657
+ least --min-seen-count, or a member's own \`<!-- meta: ... status=... -->\`
658
+ comment already declares a non-"active" status. Ranked by (summed seen_count
659
+ × distinct scopes), descending. It does not classify a trigger-context into a
660
+ glob/command/error-shape (a human step), and it does not know about
661
+ compiled_to (no such field exists yet), so an already-compiled candidate can
662
+ still surface here.
663
+
664
+ ${c.bold('Options')}
665
+ -d, --dir <path> Target project root (default: current directory)
666
+ --scope <scope> Restrict to a single scope (default: all applicable)
667
+ --min-seen-count <n> Minimum summed seen_count for a candidate (default: 3)
668
+ --json Machine-readable output (candidates + members)
669
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
670
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
671
+ --store <path> Local project-tier store directory (default: .lorekit)
672
+
673
+ ${c.bold('Examples')}
674
+ npx @lorekit/cli invariants candidates
675
+ npx @lorekit/cli invariants candidates --min-seen-count 5 --json
636
676
  `,
637
677
  link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
638
678
 
@@ -1028,12 +1068,14 @@ const KNOWN_FLAGS = [
1028
1068
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
1029
1069
  // Scale-aware survey flags
1030
1070
  'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
1071
+ // `invariants candidates`
1072
+ 'min-seen-count',
1031
1073
  // groom / policy / protect / pin / unpin
1032
1074
  'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'run',
1033
1075
  'name', 'mode', 'enabled', 'disabled',
1034
1076
  'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'off',
1035
1077
  // `obligations`
1036
- 'files', 'strict',
1078
+ 'files', 'strict', 'strict-all',
1037
1079
  ];
1038
1080
 
1039
1081
  async function main() {
@@ -1046,7 +1088,7 @@ async function main() {
1046
1088
  const argv = process.argv.slice(2);
1047
1089
  const args = parseArgs(argv, {
1048
1090
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
1049
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'strict'],
1091
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'strict', 'strict-all'],
1050
1092
  known: KNOWN_FLAGS,
1051
1093
  });
1052
1094
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.59.1",
3
+ "version": "1.61.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": {
@@ -16,6 +16,7 @@ import { deriveScope } from '../shared/scope.mjs';
16
16
  import { resolveDenies } from '../shared/control.mjs';
17
17
  import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
18
18
  import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, clusterByKeyPattern, compileKeyPattern, DEFAULT_MAX } from '../shared/lessons-view.mjs';
19
+ import { resolveRecurrenceClass } from '../shared/recurrence-clusters.mjs';
19
20
  import { log, heading, status, err, c } from '../shared/util.mjs';
20
21
 
21
22
  const DEFAULT_THRESHOLD = 0.8;
@@ -37,6 +38,15 @@ const DEDUPE_POP_CAP = 2000;
37
38
  // so we floor to a tiny epsilon rather than accept a literal 0.
38
39
  const MIN_THRESHOLD = Number.EPSILON;
39
40
 
41
+ // Attach the compile-pipeline join to every cluster: does this dedupe cluster
42
+ // already resolve to a NAMED recurrence class (`recurrence-clusters.mjs`)?
43
+ // Unconditional, like `withReadFields` — additive decoration, never a flag,
44
+ // since a null `recurrenceClass` costs nothing and a match is worth surfacing
45
+ // on every run rather than behind an opt-in.
46
+ function attachRecurrenceClasses(clusters) {
47
+ return clusters.map((cl) => ({ ...cl, recurrenceClass: resolveRecurrenceClass(cl.members) }));
48
+ }
49
+
40
50
  // Parse `--threshold` into a number in (0, 1]; anything unparseable or out of
41
51
  // range falls back to the default (never a crash on bad input). Pure-ish helper.
42
52
  export function parseThreshold(raw) {
@@ -189,9 +199,9 @@ export async function dedupe(args) {
189
199
  }
190
200
  return {
191
201
  available: true,
192
- clusters: byKeyMode
193
- ? clusterByKeyPattern(entries, keyPattern)
194
- : clusterDuplicatesBlocked(entries, threshold),
202
+ clusters: attachRecurrenceClasses(
203
+ byKeyMode ? clusterByKeyPattern(entries, keyPattern) : clusterDuplicatesBlocked(entries, threshold),
204
+ ),
195
205
  errored: flat.errored,
196
206
  popCapped,
197
207
  };
@@ -199,9 +209,9 @@ export async function dedupe(args) {
199
209
  const { entries, errored, popCapped } = await streamAccumulate(store);
200
210
  return {
201
211
  available: true,
202
- clusters: byKeyMode
203
- ? clusterByKeyPattern(entries, keyPattern)
204
- : clusterDuplicatesBlocked(entries, threshold),
212
+ clusters: attachRecurrenceClasses(
213
+ byKeyMode ? clusterByKeyPattern(entries, keyPattern) : clusterDuplicatesBlocked(entries, threshold),
214
+ ),
205
215
  errored,
206
216
  popCapped,
207
217
  };
@@ -318,6 +328,10 @@ function renderDedupeSection(header, section) {
318
328
  signal = `${cluster.size} memories, similarity ${range}`;
319
329
  }
320
330
  log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${signal})`)}`);
331
+ if (cluster.recurrenceClass?.classId) {
332
+ const pureTag = cluster.recurrenceClass.pure ? ' — pure' : ' — partial';
333
+ log(` ${c.dim(`class: ${cluster.recurrenceClass.className}${pureTag}`)}`);
334
+ }
321
335
  for (const m of cluster.members) {
322
336
  log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
323
337
  }
@@ -0,0 +1,304 @@
1
+ // `lorekit invariants <candidates>` — the compile pipeline's candidate scan.
2
+ //
3
+ // cluster → groom-merge → compile candidate → invariant
4
+ //
5
+ // `candidates` is a read-only survey over the memory store that reuses
6
+ // `dedupe`'s Jaccard clustering to find near-duplicate lessons, then ranks
7
+ // the clusters worth compiling into a hand-written `obligations-map.mjs`
8
+ // entry. It never auto-compiles and never gates anything — it prints
9
+ // candidates for a human to review, per the compile pipeline's "never
10
+ // auto-compile, never auto-gate" rule (see `../shared/recurrence-clusters.mjs`
11
+ // and `../shared/obligations-map.mjs`'s `state` ladder). For each candidate it
12
+ // prints EVERY memory the merge would collapse — that list is the whole
13
+ // point of the command, so it is the default view, not behind `--verbose`.
14
+ //
15
+ // Criteria (pure scoring in `../shared/candidates-pure.mjs`):
16
+ // - summed seen_count across a cluster's members >= --min-seen-count
17
+ // (default 3), OR any member's `<!-- meta: ... status=... -->` comment
18
+ // already declares a non-"active" status
19
+ // - ranked by (summed seen_count × distinct scopes), descending
20
+ //
21
+ // What this deliberately does NOT do:
22
+ // - classify a trigger-context into a glob/command/error-shape. The raw
23
+ // `trigger-context` string (when a lesson's meta comment carries one) is
24
+ // printed verbatim, never interpreted — "parses into a detectable
25
+ // trigger" is the human step the compile pipeline protects.
26
+ // - check `compiled_to`. No such field exists yet (no schema, no server
27
+ // support — see the kickoff's Open Questions), so a candidate already
28
+ // compiled into an obligations-map.mjs entry can still surface here. A
29
+ // known, named gap until `compiled_to` lands, not a silent omission.
30
+ //
31
+ // Value-mode clustering only (the same `clusterDuplicatesBlocked` heuristic
32
+ // `dedupe` defaults to) — key-shape clustering (`dedupe --cluster-by-key`) is
33
+ // a naming-debt signal, not a recurrence-candidate one, and stays dedupe's.
34
+ //
35
+ // Reads raw store rows directly (skipping `gather()`/`gatherStream()`'s
36
+ // `normalizeEntry`, which drops `seen_count`) because this scan's whole
37
+ // premise is the seen_count signal that `dedupe` never needed. Offline +
38
+ // Remote split and the same memory-safety population cap as `dedupe`.
39
+ //
40
+ // Registered `native` (CLI-only, no MCP tool, no REST route, no
41
+ // `tool-catalog.ts` entry) — matching how `obligations` is registered.
42
+ import process from 'node:process';
43
+ import { resolveProjectRoot } from '../shared/config.mjs';
44
+ import { deriveScope } from '../shared/scope.mjs';
45
+ import { resolveDenies } from '../shared/control.mjs';
46
+ import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
47
+ import { scopeList, clusterDuplicatesBlocked, DEFAULT_MAX } from '../shared/lessons-view.mjs';
48
+ import { rankCandidates } from '../shared/candidates-pure.mjs';
49
+ import { resolveRecurrenceClass } from '../shared/recurrence-clusters.mjs';
50
+ import { seenCountOf } from '../store/entry-fields.mjs';
51
+ import { log, heading, status, err, c } from '../shared/util.mjs';
52
+
53
+ const DEFAULT_THRESHOLD = 0.8;
54
+ const DEFAULT_MIN_SEEN_COUNT = 3;
55
+ // Mirrors dedupe's memory-safety population cap — the same super-linear
56
+ // clustering step runs here.
57
+ const POP_CAP = 2000;
58
+ const STREAM_PAGE_LIMIT = 100;
59
+
60
+ function errorMessage(res) {
61
+ return res?.networkError ?? res?.error?.message ?? res?.error ?? 'error';
62
+ }
63
+
64
+ // Local store is exhaustive and has no server-side narrowing (mirrors
65
+ // dedupe's local branch) — gather everything, then filter/cap in JS. Raw rows
66
+ // (not run through normalizeEntry) so seen_count survives.
67
+ async function localRawEntries(store, scopes, { keyPrefix, since, until, max }) {
68
+ const entries = [];
69
+ const errored = [];
70
+ for (const scope of scopes) {
71
+ let res;
72
+ try {
73
+ res = await store.list({ scope });
74
+ } catch (e) {
75
+ res = { ok: false, networkError: (e && e.message) || 'error' };
76
+ }
77
+ if (!res || res.ok === false) {
78
+ errored.push({ scope, error: errorMessage(res) });
79
+ continue;
80
+ }
81
+ for (const e of res.entries || []) entries.push({ ...e, scope: e.scope ?? scope });
82
+ }
83
+ let filtered = entries;
84
+ if (keyPrefix) filtered = filtered.filter((e) => typeof e.key === 'string' && e.key.startsWith(keyPrefix));
85
+ if (since) filtered = filtered.filter((e) => !e.created || e.created >= since);
86
+ if (until) filtered = filtered.filter((e) => !e.created || e.created < until);
87
+ if (filtered.length > max) filtered = filtered.slice(0, max);
88
+ let popCapped = false;
89
+ if (filtered.length > POP_CAP) {
90
+ filtered = filtered.slice(0, POP_CAP);
91
+ popCapped = true;
92
+ }
93
+ return { entries: filtered, errored, popCapped };
94
+ }
95
+
96
+ // Remote: paginate via cursor with the same param names gatherStream forwards
97
+ // server-side, but skip normalizeEntry so seen_count survives.
98
+ async function remoteRawEntries(store, scopes, { keyPrefix, since, until, max }) {
99
+ const entries = [];
100
+ const errored = [];
101
+ let popCapped = false;
102
+ let surveyed = 0;
103
+ for (const scope of scopes) {
104
+ if (popCapped || surveyed >= max) break;
105
+ let cursor;
106
+ // eslint-disable-next-line no-constant-condition
107
+ while (true) {
108
+ const remaining = max - surveyed;
109
+ if (remaining <= 0) break;
110
+ const pageLimit = Math.min(STREAM_PAGE_LIMIT, remaining);
111
+ let res;
112
+ try {
113
+ res = await store.list({
114
+ scope,
115
+ limit: pageLimit,
116
+ cursor,
117
+ ...(since ? { created_since: since } : {}),
118
+ ...(until ? { created_until: until } : {}),
119
+ ...(keyPrefix ? { key_prefix: keyPrefix } : {}),
120
+ });
121
+ } catch (e) {
122
+ res = { ok: false, networkError: (e && e.message) || 'error' };
123
+ }
124
+ if (!res || res.ok === false) {
125
+ errored.push({ scope, error: errorMessage(res) });
126
+ break;
127
+ }
128
+ for (const e of res.entries || []) {
129
+ if (entries.length >= POP_CAP) {
130
+ popCapped = true;
131
+ break;
132
+ }
133
+ entries.push({ ...e, scope: e.scope ?? scope });
134
+ }
135
+ surveyed += (res.entries || []).length;
136
+ if (popCapped || !res.hasMore || !res.nextCursor) break;
137
+ cursor = res.nextCursor;
138
+ if (surveyed >= max) break;
139
+ }
140
+ }
141
+ return { entries, errored, popCapped };
142
+ }
143
+
144
+ // Cluster raw entries (clusterDuplicatesBlocked only reads .scope/.key/.value,
145
+ // so raw rows work unmodified), then re-attach seenCount/value per member —
146
+ // the clusterer's own output narrows members down to {scope,key}.
147
+ function buildCandidates(entries, { threshold, minSeenCount }) {
148
+ const byAddress = new Map(entries.map((e) => [`${e.scope}::${e.key}`, e]));
149
+ const clusters = clusterDuplicatesBlocked(entries, threshold).map((cl) => ({
150
+ ...cl,
151
+ members: cl.members.map((m) => {
152
+ const raw = byAddress.get(`${m.scope}::${m.key}`);
153
+ return { scope: m.scope, key: m.key, seenCount: seenCountOf(raw), value: raw?.value ?? '' };
154
+ }),
155
+ }));
156
+ return rankCandidates(clusters, { minSeenCount, resolveClass: resolveRecurrenceClass });
157
+ }
158
+
159
+ async function candidates(args) {
160
+ const root = resolveProjectRoot(args.dir);
161
+ const env = { ...process.env };
162
+ if (args.store) env.LOREKIT_STORE = args.store;
163
+
164
+ const threshold = DEFAULT_THRESHOLD;
165
+ const minSeenCount = args['min-seen-count'] !== undefined ? Number(args['min-seen-count']) : DEFAULT_MIN_SEEN_COUNT;
166
+
167
+ const scopeInfo = deriveScope(root);
168
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
169
+
170
+ const { local, remote, connection } = resolveStores(root, {
171
+ env,
172
+ endpoint: args.endpoint,
173
+ token: args.token,
174
+ });
175
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
176
+
177
+ const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
178
+ const surveySince = args.since || undefined;
179
+ const surveyUntil = args.until || undefined;
180
+ const surveyKeyPrefix = args['key-prefix'] || undefined;
181
+ const narrow = { keyPrefix: surveyKeyPrefix, since: surveySince, until: surveyUntil, max: surveyMax };
182
+
183
+ const offlineSection = localDenied
184
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
185
+ : await (async () => {
186
+ const { entries, errored, popCapped } = await localRawEntries(local, scopes, narrow);
187
+ return { available: true, candidates: buildCandidates(entries, { threshold, minSeenCount }), errored, popCapped };
188
+ })();
189
+
190
+ const remoteAvailable = !remoteDenied && remote.usable();
191
+ const remoteSection = remoteAvailable
192
+ ? await (async () => {
193
+ const { entries, errored, popCapped } = await remoteRawEntries(remote, scopes, narrow);
194
+ return { available: true, candidates: buildCandidates(entries, { threshold, minSeenCount }), errored, popCapped };
195
+ })()
196
+ : {
197
+ available: false,
198
+ reason: remoteDenied
199
+ ? `disabled by deny constraint (${remoteDenied.source})`
200
+ : remoteUnavailableReason(connection),
201
+ };
202
+
203
+ const offlineCount = offlineSection.available ? offlineSection.candidates.length : 0;
204
+ const remoteCount = remoteSection.available ? remoteSection.candidates.length : 0;
205
+
206
+ if (args.json) {
207
+ log(
208
+ JSON.stringify(
209
+ {
210
+ root,
211
+ scopes,
212
+ minSeenCount,
213
+ offline: sectionJson(offlineSection),
214
+ remote: sectionJson(remoteSection),
215
+ },
216
+ null,
217
+ 2,
218
+ ),
219
+ );
220
+ } else {
221
+ heading('LoreKit invariants candidates');
222
+ log(` project: ${c.dim(root)}`);
223
+ log(` scopes: ${scopes.join(' → ')}`);
224
+ log(` ${c.dim(`criteria: summed seen_count >= ${minSeenCount}, or a member's meta status is non-"active"`)}`);
225
+
226
+ if (offlineSection.available && offlineSection.popCapped) {
227
+ log(` ${c.yellow('!')} population cap (${POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
228
+ }
229
+ if (remoteSection.available && remoteSection.popCapped) {
230
+ log(` ${c.yellow('!')} population cap (${POP_CAP}) reached for Remote — results are partial. Narrow with --key-prefix, --since, or --max.`);
231
+ }
232
+
233
+ renderSection({ title: 'Offline' }, offlineSection);
234
+ renderSection({ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined }, remoteSection);
235
+
236
+ log('');
237
+ const total = offlineCount + remoteCount;
238
+ if (total === 0) {
239
+ log(` ${c.green('✓')} no compile candidates at this threshold`);
240
+ } else {
241
+ const plural = total === 1 ? '' : 's';
242
+ log(` ${c.yellow('!')} ${total} candidate${plural} found — nothing here compiles or gates on its own; write an obligations-map.mjs entry by hand`);
243
+ }
244
+ log('');
245
+ }
246
+
247
+ return {
248
+ exitCode: 0,
249
+ 'lorekit.cli.invariants.candidates.scope_count': scopes.length,
250
+ 'lorekit.cli.invariants.candidates.offline_count': offlineCount,
251
+ 'lorekit.cli.invariants.candidates.remote_count': remoteCount,
252
+ 'lorekit.cli.invariants.candidates.remote_available': remoteAvailable,
253
+ };
254
+ }
255
+
256
+ function renderSection(header, section) {
257
+ heading(header.title);
258
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
259
+
260
+ if (!section.available) {
261
+ status('warn', 'unavailable', section.reason);
262
+ return;
263
+ }
264
+
265
+ for (const e of section.errored || []) {
266
+ log(` ${c.bold(e.scope)} ${c.yellow('!')} ${c.dim(e.error)}`);
267
+ }
268
+
269
+ if (!section.candidates.length) {
270
+ if (!(section.errored || []).length) log(` ${c.dim('no compile candidates')}`);
271
+ return;
272
+ }
273
+
274
+ let n = 0;
275
+ for (const cand of section.candidates) {
276
+ n += 1;
277
+ log(` ${c.yellow('•')} candidate ${n} ${c.dim(`(score ${cand.score}, ${cand.members.length} memories)`)}`);
278
+ if (cand.recurrenceClass?.classId) {
279
+ const pureTag = cand.recurrenceClass.pure ? ' — pure' : ' — partial';
280
+ log(` ${c.dim(`class: ${cand.recurrenceClass.className}${pureTag}`)}`);
281
+ }
282
+ for (const m of cand.members) {
283
+ const fields = [`seen_count=${m.seenCount}`];
284
+ if (m.meta.status) fields.push(`status=${m.meta.status}`);
285
+ if (m.meta['trigger-context']) fields.push(`trigger-context=${JSON.stringify(m.meta['trigger-context'])}`);
286
+ log(` ${c.cyan('-')} ${m.scope}::${m.key} ${c.dim(`(${fields.join(', ')})`)}`);
287
+ }
288
+ }
289
+ }
290
+
291
+ function sectionJson(section) {
292
+ if (!section.available) {
293
+ return { available: false, reason: section.reason, candidates: [], errored: [] };
294
+ }
295
+ return { available: true, candidates: section.candidates, errored: section.errored || [] };
296
+ }
297
+
298
+ export async function invariants(args) {
299
+ const sub = args._[1];
300
+ if (sub === 'candidates') return candidates(args);
301
+
302
+ err(`${c.red('Usage:')} lorekit invariants <candidates> [options]`);
303
+ return { exitCode: 1 };
304
+ }
@@ -34,7 +34,16 @@
34
34
  // stdin is not a TTY
35
35
  //
36
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.
37
+ // belonging to a `gating` entry is unmet. `run:` obliges are advisory
38
+ // (`met: null`) and never gate.
39
+ //
40
+ // `--strict` respects each entry's `state` (see `../shared/obligations-map.mjs`):
41
+ // an `advisory` entry is reported and never gates, because an entry with no
42
+ // independent `guard` asserts only its author's belief. `error-code-doc` is the
43
+ // motivating case — its own note calls it "a documented path-proxy, not a
44
+ // content predicate" that "may over-flag", and it was nonetheless failing
45
+ // `--strict` on any edit to `mcp-handler.ts`. `--strict-all` restores the
46
+ // previous behaviour of gating on every unmet obligation regardless of state.
38
47
  import process from 'node:process';
39
48
  import { log, heading, c } from '../shared/util.mjs';
40
49
  import { checkObligations } from '../shared/obligations-pure.mjs';
@@ -83,21 +92,25 @@ function dedupe(list) {
83
92
 
84
93
  export async function obligations(args) {
85
94
  const changedFiles = await resolveChangedFiles(args);
86
- const strict = Boolean(args.strict);
95
+ const strictAll = Boolean(args['strict-all']);
96
+ const strict = Boolean(args.strict) || strictAll;
87
97
  const result = checkObligations({ changedFiles, map: SURFACE_PARTNER_MAP });
98
+ const gatingUnmet = strictAll ? result.unmet : result.unmetGating;
88
99
 
89
100
  if (args.json) {
90
- log(JSON.stringify({ ...result, strict }, null, 2));
101
+ log(JSON.stringify({ ...result, strict, strictAll }, null, 2));
91
102
  } else {
92
103
  render(result, changedFiles);
93
104
  }
94
105
 
95
106
  return {
96
- exitCode: strict && result.unmet > 0 ? 1 : 0,
107
+ exitCode: strict && gatingUnmet > 0 ? 1 : 0,
97
108
  'lorekit.cli.obligations.files': changedFiles.length,
98
109
  'lorekit.cli.obligations.matched': result.matched.length,
99
110
  'lorekit.cli.obligations.unmet': result.unmet,
111
+ 'lorekit.cli.obligations.unmetGating': result.unmetGating,
100
112
  'lorekit.cli.obligations.strict': strict,
113
+ 'lorekit.cli.obligations.strictAll': strictAll,
101
114
  };
102
115
  }
103
116
 
@@ -114,7 +127,11 @@ function render(result, changedFiles) {
114
127
 
115
128
  for (const entry of result.matched) {
116
129
  log('');
117
- log(` ${c.bold(entry.id)}${entry.guard ? c.dim(` (guard: ${entry.guard})`) : ''}`);
130
+ const stateTag = entry.state === 'gating' ? c.dim('[gating]') : c.dim('[advisory]');
131
+ log(
132
+ ` ${c.bold(entry.id)} ${stateTag}${entry.guard ? c.dim(` (guard: ${entry.guard})`) : c.dim(' (no guard — advisory only)')}`,
133
+ );
134
+ if (entry.cluster) log(` ${c.dim(`class: ${entry.cluster.name}`)}`);
118
135
  if (entry.note) log(` ${c.dim(entry.note)}`);
119
136
  for (const o of entry.obliges) {
120
137
  const mark = o.kind === 'action' ? c.cyan('•') : o.met ? c.green('✓') : c.yellow('!');
@@ -130,6 +147,12 @@ function render(result, changedFiles) {
130
147
  } else {
131
148
  const plural = result.unmet === 1 ? '' : 's';
132
149
  log(` ${c.yellow('!')} ${result.unmet} unmet obligation${plural} — sweep the partner${plural} above`);
150
+ const advisoryOnly = result.unmet - result.unmetGating;
151
+ if (advisoryOnly > 0) {
152
+ log(
153
+ ` ${c.dim(`${result.unmetGating} from gating entries; ${advisoryOnly} advisory (reported, never gates)`)}`,
154
+ );
155
+ }
133
156
  }
134
157
  log('');
135
158
  }
package/src/commands.mjs CHANGED
@@ -48,6 +48,7 @@ import { tree } from './commands/tree.mjs';
48
48
  import { lint } from './commands/lint.mjs';
49
49
  import { dedupe } from './commands/dedupe.mjs';
50
50
  import { obligations } from './commands/obligations.mjs';
51
+ import { invariants } from './commands/invariants.mjs';
51
52
  import { link } from './commands/link.mjs';
52
53
  import { hook } from './commands/hook.mjs';
53
54
  import { migrate } from './commands/migrate.mjs';
@@ -83,6 +84,7 @@ export const COMMANDS = [
83
84
  { name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' },
84
85
  { name: 'dedupe', run: dedupe, traced: true, strictFlags: true, native: 'near-duplicate detection across a scope' },
85
86
  { name: 'obligations', run: obligations, traced: true, strictFlags: true, native: 'checks changed files against the surface-partner map' },
87
+ { name: 'invariants', run: invariants, traced: true, strictFlags: true, native: 'compile-pipeline candidate scan over the memory store' },
86
88
  { name: 'link', run: link, traced: true, strictFlags: true, native: 'builds a dashboard deep link', aliases: ['url'] },
87
89
  { name: 'migrate', run: migrate, traced: true, strictFlags: true, native: 'moves lore between local and remote stores' },
88
90
  { name: 'bootstrap', run: bootstrap, traced: true, strictFlags: true, native: 'seeds a fresh store from a template' },