@lorekit/cli 1.60.0 → 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 +55 -4
- package/bin/lorekit.mjs +47 -5
- package/package.json +1 -1
- package/src/commands/dedupe.mjs +20 -6
- package/src/commands/invariants.mjs +304 -0
- package/src/commands/obligations.mjs +28 -5
- package/src/commands.mjs +2 -0
- package/src/shared/candidates-pure.mjs +110 -0
- package/src/shared/completions.mjs +5 -1
- package/src/shared/obligations-map.mjs +79 -23
- package/src/shared/obligations-pure.mjs +27 -10
- package/src/shared/recurrence-clusters.mjs +161 -0
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
|
-
|
|
491
|
-
|
|
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
|
|
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
|
|
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
|
|
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
package/src/commands/dedupe.mjs
CHANGED
|
@@ -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:
|
|
193
|
-
? clusterByKeyPattern(entries, keyPattern)
|
|
194
|
-
|
|
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:
|
|
203
|
-
? clusterByKeyPattern(entries, keyPattern)
|
|
204
|
-
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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' },
|
|
@@ -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'] },
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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()]
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
+
}
|