@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.
@@ -0,0 +1,110 @@
1
+ // Pure core for `lorekit invariants candidates` — ranks near-duplicate
2
+ // clusters (the same Jaccard heuristic `dedupe` uses) as merge candidates for
3
+ // a hand-written compile-pipeline invariant declaration.
4
+ //
5
+ // This module is deliberately read-only in a stronger sense than "no writes":
6
+ // it never CLASSIFIES anything. It surfaces what a lesson already states about
7
+ // itself (a parsed meta comment, a seen_count, a resolved recurrence class)
8
+ // and ranks it — it does not decide whether a trigger-context is mechanically
9
+ // detectable, and it does not validate `status` against a fixed vocabulary
10
+ // (none is defined anywhere in this codebase yet). That judgment is the human
11
+ // step the compile pipeline's "never auto-compile, never auto-gate" rule
12
+ // protects; a scan that started making those calls would be the thing the
13
+ // rule exists to prevent.
14
+ //
15
+ // Operates on already-enriched cluster members — `{ scope, key, seenCount,
16
+ // value }` — leaving the store I/O and the seen_count/meta correlation to the
17
+ // command (`../commands/invariants.mjs`). Zero-dep, total: malformed input
18
+ // degrades to the empty/default case rather than throwing.
19
+
20
+ const META_COMMENT_RE = /<!--\s*meta:([\s\S]*?)-->/;
21
+ const META_FIELD_RE = /([\w-]+)=("(?:[^"\\]|\\.)*"|\S+)/g;
22
+
23
+ /**
24
+ * Extract the `<!-- meta: seen_count=1 status=active expires=<iso>
25
+ * trigger-context="<signal>" -->` convention documented in the lorekit-setup
26
+ * skill's `self-improvement-loops.md`. Nothing in the CLI parses this
27
+ * convention today; this is read-only extraction for a human's judgment, not
28
+ * a schema the scan enforces. Absent or malformed input yields `{}`, never a
29
+ * throw — a lesson written before (or without) the convention is not an
30
+ * error, just a candidate with no meta fields.
31
+ */
32
+ export function parseMetaComment(value) {
33
+ if (typeof value !== 'string') return {};
34
+ const m = META_COMMENT_RE.exec(value);
35
+ if (!m) return {};
36
+ const out = {};
37
+ META_FIELD_RE.lastIndex = 0;
38
+ let mm;
39
+ while ((mm = META_FIELD_RE.exec(m[1]))) {
40
+ const [, k, rawV] = mm;
41
+ out[k] =
42
+ rawV.startsWith('"') && rawV.endsWith('"') ? rawV.slice(1, -1).replace(/\\"/g, '"') : rawV;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function totalSeen(members) {
48
+ return (members || []).reduce((n, m) => n + (Number.isFinite(m.seenCount) ? m.seenCount : 0), 0);
49
+ }
50
+
51
+ function distinctScopeCount(members) {
52
+ return new Set((members || []).map((m) => m.scope)).size;
53
+ }
54
+
55
+ /**
56
+ * A cluster is a candidate when its recurrence signal is real: either the
57
+ * summed `seenCount` across members crosses `minSeenCount` (default 3 — the
58
+ * kickoff's "seen_count >= 3" criterion, applied to the SUM across the
59
+ * cluster's members rather than any single one, since the whole pitch of a
60
+ * candidate is "these N sightings are really one entry"), or a member's own
61
+ * meta comment already declares a non-"active" status.
62
+ */
63
+ export function isCandidate(members, { minSeenCount = 3 } = {}) {
64
+ if (totalSeen(members) >= minSeenCount) return true;
65
+ return (members || []).some((m) => {
66
+ const status = parseMetaComment(m.value).status;
67
+ return typeof status === 'string' && status.length > 0 && status !== 'active';
68
+ });
69
+ }
70
+
71
+ /** Recurrence × distinct scopes — the ranking signal the kickoff names. */
72
+ export function scoreCandidate(members) {
73
+ return totalSeen(members) * distinctScopeCount(members);
74
+ }
75
+
76
+ /**
77
+ * Filter a set of dedupe-style clusters down to candidates and rank them,
78
+ * highest score first (ties broken by member count, then the first member's
79
+ * `scope::key` for determinism). Each input cluster is
80
+ * `{ members: [{scope,key,seenCount,value}], size, minSimilarity?,
81
+ * maxSimilarity? }`. Never mutates the input.
82
+ *
83
+ * `resolveClass`, when supplied, is called with the cluster's raw members to
84
+ * attach a resolved recurrence class (the `dedupe` join, reused here) — a
85
+ * candidate already resolving to a known class is a stronger case ("this
86
+ * should join an existing invariant") than one that doesn't ("this might be a
87
+ * new class"). Optional so the pure ranking logic stays testable without it.
88
+ */
89
+ export function rankCandidates(clusters, { minSeenCount = 3, resolveClass } = {}) {
90
+ return (clusters || [])
91
+ .filter((cl) => isCandidate(cl.members, { minSeenCount }))
92
+ .map((cl) => {
93
+ const members = (cl.members || []).map((m) => ({ ...m, meta: parseMetaComment(m.value) }));
94
+ return {
95
+ members,
96
+ size: cl.size ?? members.length,
97
+ minSimilarity: cl.minSimilarity,
98
+ maxSimilarity: cl.maxSimilarity,
99
+ recurrenceClass: typeof resolveClass === 'function' ? resolveClass(cl.members) : null,
100
+ score: scoreCandidate(cl.members),
101
+ };
102
+ })
103
+ .sort((a, b) => {
104
+ if (b.score !== a.score) return b.score - a.score;
105
+ if (b.size !== a.size) return b.size - a.size;
106
+ const aKey = `${a.members[0]?.scope}::${a.members[0]?.key}`;
107
+ const bKey = `${b.members[0]?.scope}::${b.members[0]?.key}`;
108
+ return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
109
+ });
110
+ }
@@ -71,6 +71,8 @@ const FLAG = {
71
71
  'retention-days': { desc: 'Only purge archived older than n days', arg: 'n' },
72
72
  files: { desc: 'Changed files to check', arg: 'path' },
73
73
  strict: { desc: 'Exit non-zero on any unmet obligation' },
74
+ 'strict-all': { desc: 'Exit non-zero on ANY unmet obligation, advisory included' },
75
+ 'min-seen-count': { desc: 'Minimum summed seen_count for a candidate', arg: 'n' },
74
76
  'policy-id': { desc: 'Run/preview a saved policy', arg: 'id' },
75
77
  'min-age-days': { desc: 'Match lessons at least n days old', arg: 'n' },
76
78
  'unseen-days': { desc: 'Match lessons unseen for at least n days', arg: 'n' },
@@ -121,7 +123,9 @@ const COMMANDS = [
121
123
  flags: ['dir', 'scope', 'threshold', 'cluster-by-key', 'json', 'endpoint', 'token', 'store'] },
122
124
  { name: 'obligations', summary: 'Check changed files against the surface-partner map',
123
125
  positional: 'path',
124
- flags: ['files', 'strict', 'json'] },
126
+ flags: ['files', 'strict', 'strict-all', 'json'] },
127
+ { name: 'invariants', summary: "Compile pipeline's candidate scan (`invariants candidates`)",
128
+ flags: ['dir', 'scope', 'min-seen-count', 'json', 'endpoint', 'token', 'store'] },
125
129
  { name: 'link', summary: 'Print a shareable dashboard deep-link URL', aliases: ['url'],
126
130
  positional: 'address',
127
131
  flags: ['dir', 'scope', 'key', 'q', 'owner', 'tags', 'range', 'from', 'to', 'archived', 'base', 'json'] },
@@ -46,6 +46,9 @@ export const LORE_PARAM_DEFAULTS = {
46
46
  // "unfiltered".
47
47
  filters: null,
48
48
  tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
49
+ // RetentionConditions | null — the retention-preview trio (min age / unseen-for
50
+ // / seen-at-most). `null` means no narrowing; absent from the URL when unset.
51
+ retention: null,
49
52
  // 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
50
53
  // `null`, NOT 'active', is the default for `filters`' reason: the app has to
51
54
  // tell "absent" from an explicit choice, because an absent `status` falls back
@@ -59,9 +62,21 @@ export const LORE_PARAM_DEFAULTS = {
59
62
 
60
63
  // A stable, readable param order (also makes URLs deterministic for tests).
61
64
  // Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
62
- // param last), so `filters` and `tags` sit between `owner` and `status`. `scope`
63
- // precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
64
- const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'status', 'archived', 'lesson'];
65
+ // param last), so `filters` and `tags` sit between `owner` and `status`, and
66
+ // `retention` sits between `tags` and `status`. `scope` precedes `lesson` so a
67
+ // lesson link reads `?scope=…&lesson=…`.
68
+ const PARAM_ORDER = [
69
+ 'scope',
70
+ 'q',
71
+ 'range',
72
+ 'owner',
73
+ 'filters',
74
+ 'tags',
75
+ 'retention',
76
+ 'status',
77
+ 'archived',
78
+ 'lesson',
79
+ ];
65
80
 
66
81
  // Strip trailing slashes from a base URL, falling back to the default when the
67
82
  // input is empty/absent. Pure.
@@ -31,7 +31,32 @@
31
31
  // // when a name-derived module's real partner
32
32
  // // could live under either of two sibling
33
33
  // // directories)
34
- // lessonKey: string, // canonical memory key to cite
34
+ // state: 'advisory'|'gating'|'retired',
35
+ // // advisory: reported, gates nothing.
36
+ // // gating: fails --strict. An entry may only
37
+ // // be `gating` if it has a `guard` — an
38
+ // // independent source that already asserts
39
+ // // the partnership. Without one, the entry
40
+ // // asserts only its own author's belief, and
41
+ // // `perf-index` / `error-code-doc` say as
42
+ // // much in their notes. Enforced in
43
+ // // obligations.test.mjs.
44
+ // // retired: not checked; kept for provenance.
45
+ // cluster: string, // the recurrence class this entry
46
+ // // instantiates (`./recurrence-clusters.mjs`).
47
+ // // Supplies the lesson key to cite, and
48
+ // // groups entries that share a root cause.
49
+ // lessonKey?: string, // an entry-specific memory key, overriding
50
+ // // its cluster's canonical one. Rare.
51
+ // owner: string, // who reviews this entry when it misfires.
52
+ // // An entry with no owner is cruft with a CI
53
+ // // job attached.
54
+ // added: string, // ISO date
55
+ // reviewBy: string, // ISO date. Memories expire; without this the
56
+ // // map is the one place in the system under
57
+ // // no decay pressure, and it accumulates the
58
+ // // way the lesson variants it replaced did.
59
+ // // Guard-less entries get a shorter horizon.
35
60
  // guard?: string, // the existing CI spec/script that enforces
36
61
  // // this partnership, if any
37
62
  // note?: string, // why this entry exists / its limitations
@@ -56,19 +81,6 @@
56
81
 
57
82
  import { mirrorPairs } from './mirror-pairs.mjs';
58
83
 
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
84
  const EDGE_MIRROR_GUARD = 'packages/mcp-core/src/edge/edge-parity.spec.ts';
73
85
 
74
86
  // One `{ match, obliges }` row PER KNOWN PAIR, in both directions — see the
@@ -76,17 +88,25 @@ const EDGE_MIRROR_GUARD = 'packages/mcp-core/src/edge/edge-parity.spec.ts';
76
88
  const EDGE_MIRROR_ENTRIES = mirrorPairs.flatMap(({ core, edge }) => [
77
89
  {
78
90
  id: 'edge-mirror',
91
+ state: 'gating',
92
+ owner: '@mthines',
93
+ added: '2026-08-26',
94
+ reviewBy: '2027-02-26',
79
95
  match: edge,
80
96
  obliges: [core],
81
- lessonKey: COPIES_A_CLAIM_LESSON,
97
+ cluster: 'copies-a-claim',
82
98
  guard: EDGE_MIRROR_GUARD,
83
99
  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
100
  },
85
101
  {
86
102
  id: 'edge-mirror-core',
103
+ state: 'gating',
104
+ owner: '@mthines',
105
+ added: '2026-08-26',
106
+ reviewBy: '2027-02-26',
87
107
  match: core,
88
108
  obliges: [edge],
89
- lessonKey: COPIES_A_CLAIM_LESSON,
109
+ cluster: 'copies-a-claim',
90
110
  guard: EDGE_MIRROR_GUARD,
91
111
  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
112
  },
@@ -96,6 +116,10 @@ export const SURFACE_PARTNER_MAP = [
96
116
  ...EDGE_MIRROR_ENTRIES,
97
117
  {
98
118
  id: 'tool-catalog',
119
+ state: 'gating',
120
+ owner: '@mthines',
121
+ added: '2026-08-26',
122
+ reviewBy: '2027-02-26',
99
123
  match: 'packages/schemas/src/shared/tool-catalog.ts',
100
124
  obliges: [
101
125
  'supabase/functions/mcp/tool-dispatch.generated.ts',
@@ -103,52 +127,84 @@ export const SURFACE_PARTNER_MAP = [
103
127
  'packages/web/public/llms.txt',
104
128
  'run:pnpm nx generate:llms schemas',
105
129
  ],
106
- lessonKey: COPIES_A_CLAIM_LESSON,
130
+ cluster: 'copies-a-claim',
107
131
  guard: 'packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts, scripts/codegen/gen-surfaces.mjs --check',
108
132
  note: 'The catalog is the single origin of the operation surface — every generated projection of it must be regenerated in the same commit.',
109
133
  },
110
134
  {
111
135
  id: 'llms-generated',
136
+ state: 'gating',
137
+ owner: '@mthines',
138
+ added: '2026-08-26',
139
+ reviewBy: '2027-02-26',
112
140
  match: ['packages/schemas/src/llms/template.md', 'packages/schemas/src/shared/tool-catalog.ts'],
113
141
  obliges: ['packages/web/public/llms.txt', 'run:pnpm nx generate:llms schemas'],
114
- lessonKey: COPIES_A_CLAIM_LESSON,
142
+ cluster: 'copies-a-claim',
115
143
  guard: 'packages/schemas/src/llms/render.spec.ts',
116
144
  note: 'llms.txt is GENERATED — never hand-edited; the committed file must be what the generator produces from these two sources.',
117
145
  },
118
146
  {
119
147
  id: 'docs-section',
148
+ state: 'gating',
149
+ owner: '@mthines',
150
+ added: '2026-08-26',
151
+ reviewBy: '2027-02-26',
120
152
  match: 're:^packages/web/src/content/docs/[^/]+\\.mdx$',
121
153
  obliges: ['packages/web/src/lib/docs/sections.ts'],
122
- lessonKey: SIBLING_SET_LESSON,
154
+ cluster: 'sibling-set',
123
155
  guard: 'packages/web/src/lib/docs/sections.spec.ts',
124
156
  note: 'A new/removed docs page needs its DOCS_SECTIONS entry, or the site index and the page itself drift apart.',
125
157
  },
126
158
  {
127
159
  id: 'plugin-skill',
160
+ state: 'gating',
161
+ owner: '@mthines',
162
+ added: '2026-08-26',
163
+ reviewBy: '2027-02-26',
128
164
  match: 'packages/cli/skill/**',
129
165
  obliges: ['run:node scripts/codegen/sync-plugin-skill.mjs', 'plugins/lorekit-claude/skills/**'],
130
- lessonKey: SIBLING_SET_LESSON,
166
+ cluster: 'sibling-set',
131
167
  guard: 'scripts/codegen/sync-plugin-skill.mjs --check',
132
168
  note: 'The Claude plugin vendors a copy of every skill/* source — regenerate the mirror, never hand-edit it.',
133
169
  },
170
+ {
171
+ id: 'cli-flag-doc',
172
+ state: 'advisory',
173
+ owner: '@mthines',
174
+ added: '2026-08-31',
175
+ reviewBy: '2026-11-30',
176
+ match: 'packages/cli/bin/lorekit.mjs',
177
+ obliges: ['docs/cli.md', 'packages/cli/README.md', 'CLAUDE.md'],
178
+ cluster: 'copies-a-claim',
179
+ guard: null,
180
+ note: 'The flag table and per-command help in bin/lorekit.mjs is the origin of every flag claim; docs/cli.md, the package README (prose AND its flag table) and CLAUDE.md each restate it. Found by running this command against its own changed-set while changing the meaning of --strict: nothing fired, because the map covered every generated surface and no hand-written one. A path proxy, not a content predicate — bin/lorekit.mjs changes for reasons unrelated to flags — so advisory by construction, like error-code-doc.',
181
+ },
134
182
  {
135
183
  id: 'perf-index',
184
+ state: 'advisory',
185
+ owner: '@mthines',
186
+ added: '2026-08-26',
187
+ reviewBy: '2026-11-26',
136
188
  match: 're:^supabase/migrations/.*index.*\\.sql$',
137
189
  obliges: ['supabase/tests/migrations.test.sql'],
138
- lessonKey: SIBLING_SET_LESSON,
190
+ cluster: 'sibling-set',
139
191
  guard: null,
140
192
  note: 'Convention only — a real gap. A new index migration has no automated nudge to add its coverage to the migrations test.',
141
193
  },
142
194
  {
143
195
  id: 'error-code-doc',
196
+ state: 'advisory',
197
+ owner: '@mthines',
198
+ added: '2026-08-26',
199
+ reviewBy: '2026-11-26',
144
200
  match: ['supabase/functions/mcp/mcp-handler.ts', 'packages/mcp-core/src/auth/account-wide-tools.ts'],
145
201
  obliges: [
146
202
  'docs/mcp-tools.md',
147
203
  'packages/schemas/src/llms/template.md',
148
204
  'packages/web/public/llms.txt',
149
205
  ],
150
- lessonKey: COPIES_A_CLAIM_LESSON,
206
+ cluster: 'copies-a-claim',
151
207
  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).',
208
+ 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. State is `advisory` for that reason: it is reported on every hit and gates nothing, not even under --strict (only --strict-all).',
153
209
  },
154
210
  ];
@@ -45,6 +45,8 @@
45
45
  // `stemOf` (below) is the simpler, general-purpose primitive — a plain
46
46
  // basename-without-extension — exported for standalone use and unit testing.
47
47
 
48
+ import { clusterForEntry, lessonKeyForEntry } from './recurrence-clusters.mjs';
49
+
48
50
  export const RUN_PREFIX = 'run:';
49
51
  export const REGEX_PREFIX = 're:';
50
52
 
@@ -217,10 +219,15 @@ export function checkObligations({ changedFiles = [], map = [] } = {}) {
217
219
 
218
220
  let bucket = byId.get(entry.id);
219
221
  if (!bucket) {
222
+ const cluster = clusterForEntry(entry);
220
223
  bucket = {
221
224
  id: entry.id,
222
- lessonKey: entry.lessonKey ?? null,
225
+ state: entry.state ?? 'advisory',
226
+ cluster: cluster ? { id: cluster.id, name: cluster.name, why: cluster.why } : null,
227
+ lessonKey: lessonKeyForEntry(entry),
223
228
  guard: entry.guard ?? null,
229
+ owner: entry.owner ?? null,
230
+ reviewBy: entry.reviewBy ?? null,
224
231
  note: entry.note ?? null,
225
232
  obliges: new Map(),
226
233
  };
@@ -232,15 +239,25 @@ export function checkObligations({ changedFiles = [], map = [] } = {}) {
232
239
  }
233
240
  }
234
241
 
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
+ const matched = [...byId.values()]
243
+ .filter((b) => b.state !== 'retired')
244
+ .map((b) => ({
245
+ id: b.id,
246
+ state: b.state,
247
+ cluster: b.cluster,
248
+ lessonKey: b.lessonKey,
249
+ guard: b.guard,
250
+ owner: b.owner,
251
+ reviewBy: b.reviewBy,
252
+ note: b.note,
253
+ obliges: [...b.obliges.values()],
254
+ }));
255
+
256
+ const countUnmet = (entries) =>
257
+ entries.reduce((n, e) => n + e.obliges.filter((o) => o.met === false).length, 0);
242
258
 
243
- const unmet = matched.reduce((n, e) => n + e.obliges.filter((o) => o.met === false).length, 0);
259
+ const unmet = countUnmet(matched);
260
+ const unmetGating = countUnmet(matched.filter((e) => e.state === 'gating'));
244
261
 
245
- return { files, matched, unmet, ok: unmet === 0 };
262
+ return { files, matched, unmet, unmetGating, ok: unmet === 0, okGating: unmetGating === 0 };
246
263
  }
@@ -0,0 +1,161 @@
1
+ // Recurrence clusters — the named classes of repeated failure that the
2
+ // Surface-Partner Map's entries instantiate.
3
+ //
4
+ // This module formalizes something the map already did informally. Before it,
5
+ // `obligations-map.mjs` held two bare string constants —
6
+ // `COPIES_A_CLAIM_LESSON` and `SIBLING_SET_LESSON` — whose own comments called
7
+ // them "the flagship recurrence class" and "the registered-everywhere /
8
+ // sibling-set recurrence class". Eight entries, two classes: the clustering had
9
+ // been done by hand and encoded as a variable name.
10
+ //
11
+ // Naming it makes three things possible that a bare constant does not:
12
+ //
13
+ // 1. A cluster can carry WHY it recurs and WHAT ITS SHAPE IS, not just which
14
+ // lesson key to cite. That text is what a human hitting the check needs.
15
+ // 2. `checkObligations` can report the cluster alongside the entry, so two
16
+ // unmet obligations from the same root cause read as one problem.
17
+ // 3. It is the join point for the compile pipeline: a cluster is what a
18
+ // candidates scan over the memory store produces (N near-duplicate
19
+ // memories that are really one class), and an invariant entry is what a
20
+ // human writes from a cluster. The `sourceKeys` field records that origin
21
+ // in the only direction currently available — from the entry back to the
22
+ // memories.
23
+ //
24
+ // Pure and zero-dep by design, like `obligations-pure.mjs`: no filesystem, no
25
+ // cwd, no network. The registry is data.
26
+ //
27
+ // Cluster schema:
28
+ // {
29
+ // id: string, // stable slug, referenced by map entries' `cluster`
30
+ // name: string, // one line, the shape of the recurrence
31
+ // lessonKey: string, // canonical memory key an entry in this class cites
32
+ // why: string, // why this class recurs — shown on a hit
33
+ // sourceKeys?: string[] // other memory keys this class subsumes, if known
34
+ // }
35
+
36
+ /**
37
+ * The named recurrence classes. Adding one is a deliberate act: a cluster
38
+ * asserts that several distinct memories are really one class, which is the
39
+ * claim a groom-merge would act on. Two entries sharing a `lessonKey` but not
40
+ * a cluster is a smell — they are the same class and should say so.
41
+ */
42
+ export const RECURRENCE_CLUSTERS = [
43
+ {
44
+ id: 'copies-a-claim',
45
+ name: 'A partner copies a claim and goes stale when the source changes',
46
+ lessonKey:
47
+ 'implement-suggestion-lessons::a-mechanism-clause-you-correct-in-the-pr-body-must-be-corrected-in-every-doc-that-copies-it',
48
+ why: 'Some surface — a mirrored module, a generated artifact, a doc paragraph — restates a claim it does not own. Changing the origin does not change the copy, and nothing in the edit itself points at the copy.',
49
+ },
50
+ {
51
+ id: 'sibling-set',
52
+ name: 'A set-enumerating surface gains a hole when a member is added or moved',
53
+ lessonKey: 'aw-lessons::docs-drift-grep-must-search-names-not-invocation',
54
+ why: 'Several surfaces enumerate a set (every command, every docs page, every mirrored file). Adding or moving a member leaves each enumeration silently incomplete, and a grep for the invocation rather than the name misses them.',
55
+ },
56
+ ];
57
+
58
+ const BY_ID = new Map(RECURRENCE_CLUSTERS.map((cl) => [cl.id, cl]));
59
+ const BY_LESSON_KEY = new Map(RECURRENCE_CLUSTERS.map((cl) => [cl.lessonKey, cl]));
60
+
61
+ /** The cluster with this id, or null. Total: an unknown id is not an error. */
62
+ export function clusterById(id) {
63
+ if (typeof id !== 'string' || !id) return null;
64
+ return BY_ID.get(id) ?? null;
65
+ }
66
+
67
+ /**
68
+ * The cluster that owns this lesson key, or null. Lets an entry that still
69
+ * carries a bare `lessonKey` (rather than a `cluster` id) resolve to its class,
70
+ * so the two forms coexist during the migration.
71
+ */
72
+ export function clusterForLessonKey(lessonKey) {
73
+ if (typeof lessonKey !== 'string' || !lessonKey) return null;
74
+ return BY_LESSON_KEY.get(lessonKey) ?? null;
75
+ }
76
+
77
+ /**
78
+ * Resolve an entry's cluster from either form — an explicit `cluster` id wins,
79
+ * a bare `lessonKey` falls back to reverse lookup. Returns null when the entry
80
+ * belongs to no known class, which is legal but worth reporting: an entry with
81
+ * no cluster is a one-off, and a one-off is weak evidence for a check.
82
+ */
83
+ export function clusterForEntry(entry) {
84
+ if (!entry || typeof entry !== 'object') return null;
85
+ return clusterById(entry.cluster) ?? clusterForLessonKey(entry.lessonKey);
86
+ }
87
+
88
+ /**
89
+ * The lesson key an entry should cite: its own explicit `lessonKey` if it has
90
+ * one, otherwise its cluster's canonical key.
91
+ */
92
+ export function lessonKeyForEntry(entry) {
93
+ if (!entry || typeof entry !== 'object') return null;
94
+ if (typeof entry.lessonKey === 'string' && entry.lessonKey) return entry.lessonKey;
95
+ return clusterForEntry(entry)?.lessonKey ?? null;
96
+ }
97
+
98
+ /** Every map entry belonging to a cluster, by id. Order preserved. */
99
+ export function clusterMembers(clusterId, map = []) {
100
+ if (!Array.isArray(map)) return [];
101
+ const seen = new Set();
102
+ const out = [];
103
+ for (const entry of map) {
104
+ if (clusterForEntry(entry)?.id !== clusterId) continue;
105
+ if (!entry.id || seen.has(entry.id)) continue;
106
+ seen.add(entry.id);
107
+ out.push(entry.id);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * The join to `dedupe`: does a group of memory keys (a dedupe cluster's
114
+ * members) already belong to a NAMED recurrence class? A member's `key`
115
+ * resolves to a class when it equals that class's canonical `lessonKey` or
116
+ * appears in its `sourceKeys` — the other sightings the class is known to
117
+ * subsume.
118
+ *
119
+ * This is a stronger signal than lexical similarity alone: a dedupe cluster
120
+ * that resolves here isn't just "these look alike", it's "this is (at least
121
+ * partly) another sighting of a class we already named and can cite."
122
+ *
123
+ * `pure: true` only when EVERY member resolves to the SAME single class — the
124
+ * strongest case, where merging under the class's canonical key loses no
125
+ * stragglers. A mixed match (some members resolve, some don't, or they split
126
+ * across classes) still reports the majority class via `matched`, so a
127
+ * partial hit isn't silently discarded — ties break by registry order.
128
+ *
129
+ * Total: no members, or no member resolving to any class, returns the null
130
+ * shape rather than throwing or returning undefined fields.
131
+ */
132
+ export function resolveRecurrenceClass(members = [], clusters = RECURRENCE_CLUSTERS) {
133
+ const list = Array.isArray(members) ? members : [];
134
+ const counts = new Map();
135
+ const matched = [];
136
+ for (const m of list) {
137
+ const key = m?.key;
138
+ if (typeof key !== 'string' || !key) continue;
139
+ for (const cl of clusters) {
140
+ const sourceKeys = Array.isArray(cl.sourceKeys) ? cl.sourceKeys : [];
141
+ if (key !== cl.lessonKey && !sourceKeys.includes(key)) continue;
142
+ matched.push(key);
143
+ counts.set(cl.id, (counts.get(cl.id) ?? 0) + 1);
144
+ break;
145
+ }
146
+ }
147
+ if (matched.length === 0) return { classId: null, className: null, matched: [], pure: false };
148
+
149
+ let bestId = null;
150
+ let bestCount = -1;
151
+ for (const cl of clusters) {
152
+ const n = counts.get(cl.id) ?? 0;
153
+ if (n > bestCount) {
154
+ bestCount = n;
155
+ bestId = cl.id;
156
+ }
157
+ }
158
+ const best = clusters.find((cl) => cl.id === bestId) ?? null;
159
+ const pure = matched.length === list.length && counts.size === 1;
160
+ return { classId: best?.id ?? null, className: best?.name ?? null, matched, pure };
161
+ }