@lorekit/cli 1.67.0 → 1.69.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.
@@ -21,6 +21,8 @@ The design has two tiers connected by a recurrence gate. Both run on LoreKit.
21
21
  - [Read step (start of every run)](#read-step-start-of-every-run)
22
22
  - [Write step (on failure / at the end of a run)](#write-step-on-failure--at-the-end-of-a-run)
23
23
  - [The reconcile-on-re-run flow (resolve + record)](#the-reconcile-on-re-run-flow-resolve--record)
24
+ - [Cross-bucket reads (targeted, read-only)](#cross-bucket-reads-targeted-read-only)
25
+ - [Shared codebase-knowledge (the standard cross-loop layer)](#shared-codebase-knowledge-the-standard-cross-loop-layer)
24
26
  - [Promotion (fast → slow)](#promotion-fast--slow)
25
27
  - [Entrenchment guards (do not skip these)](#entrenchment-guards-do-not-skip-these)
26
28
  - [Wiring checklist](#wiring-checklist)
@@ -69,6 +71,11 @@ across runs** — recurrence is the cheap external signal that the lesson is rea
69
71
  not a one-off. This is the episodic → procedural promotion path, with a human
70
72
  gate on the slow tier so a single bad run can never rewrite the host.
71
73
 
74
+ Both tiers are **read by people as well as agents** — a teammate browsing the
75
+ dashboard, a reviewer asking why a rule exists. That is why a lesson body is
76
+ plain markdown with no hidden payload; see
77
+ [the lesson record](#the-lesson-record).
78
+
72
79
  The fast tier is optional: if LoreKit's `memory.*` tools are not connected, the
73
80
  loop is a silent no-op (log one line, continue). The slow tier is just editing
74
81
  the host and is unaffected.
@@ -99,24 +106,115 @@ Reserve `branch::` for throwaway notes; a loop normally writes `global` or
99
106
  ### The lesson record
100
107
 
101
108
  A loop lesson is **procedural** ("how to do better next time"), not a fact about
102
- the user. Keep the machine-read metadata in a `meta:` comment at the top of the
103
- `value` so the prose stays readable:
109
+ the user. The `value` is **markdown a human reads** — in the dashboard, in a
110
+ SessionStart injection, in another agent's context — and nothing else. Write it
111
+ to this shape:
104
112
 
105
113
  ```markdown
106
- <!-- meta: seen_count=1 status=active expires=<ISO 8601, ~90 days out> trigger-context="<concrete signal — file glob, task type, tool, error shape>" -->
114
+ # <one-line takeaway — what to do, not what the lesson is about>
107
115
 
108
- # <one-line lesson title>
116
+ **Applies when:** <concrete signal — file glob, task type, tool name, error shape>
109
117
 
110
- **What failed:** <concrete observable from the run>
118
+ **What happened:** <the concrete observable from the run>
111
119
  **Why:** <root cause, if known; "unknown" is allowed>
112
- **What to do next time:** <prescriptive, actionable, testable instruction>
120
+ **Do this instead:** <prescriptive, actionable, testable instruction>
113
121
  **Promotion target:** <the host rule/step this would harden if promoted, or "none">
114
122
  ```
115
123
 
116
- `trigger-context` must be **concrete** (globs, task types, tool names, error
117
- shapes) — never "when it feels relevant" — so the read step can match it
118
- mechanically. `seen_count`, `status`, and `expires` drive recurrence, promotion,
119
- and decay.
124
+ `Applies when` must be **concrete** (globs, task types, tool names, error
125
+ shapes) — never "when it feels relevant" — so the read step can match it against
126
+ the current run. It is a visible line, not hidden metadata: the reader deciding
127
+ whether a lesson is theirs needs it first, which is why it sits directly under
128
+ the title.
129
+
130
+ #### Never put machine metadata in the body
131
+
132
+ **A lesson body carries no hidden or machine-only payload — no HTML comment, no
133
+ front-matter block, no JSON blob, no `key=value` header.** Every byte of `value`
134
+ must render as prose a person can read. This is not a style preference; a body
135
+ field is the wrong home for each of these on the merits:
136
+
137
+ | Fact | Where it belongs | Why not the body |
138
+ | ---- | ---------------- | ---------------- |
139
+ | **Recurrence count** | `seen_count`, a store column | `memory_write` sets `seen_count = memories.seen_count + 1` on every overwrite. A count written into prose is a snapshot the writer guessed at and nothing ever updates — stale the first time the lesson recurs, and readers of the real counter never see it |
140
+ | **Expiry** | `ttl_days` on the write (`clear_ttl` to make permanent) | Expiry is enforced against the column by `memory.purge_expired` and the read filters. A date in prose expires nothing |
141
+ | **Status** (`structural`, `promoted`) | a `status::<value>` tag | Tags are first-class and filterable — `memory.list { tags: ["status::structural"] }` finds them. Prose is not queryable |
142
+ | **Owning host / bucket kind** | the `host` and `kind` write fields | Both are first-class, inferred from the `loop::<host>-lessons` tag when omitted, and drive the Explorer's own facets |
143
+ | **Provenance** (repo, branch, commit, PR) | `origin_repo` / `origin_branch` / `origin_commit` / `origin_pr` | First-class, and the dashboard renders them as links |
144
+ | **Trigger** (`stuck-loop`, `command-failure`, …) | the `trigger` write field | Already a facet; restating it in prose adds a line and no information |
145
+
146
+ An HTML comment is the worst of both worlds: markdown renders it to *nothing*, so
147
+ a human sees a lesson that starts mid-thought, while the fields inside it silently
148
+ disagree with the store's own columns. If a fact has a first-class home, put it
149
+ there and leave it out of the prose entirely.
150
+
151
+ This rule governs **lesson** bodies. A CI **state record** is a different shape
152
+ on purpose — its whole `value` is a JSON object, authoritative and parsed rather
153
+ than read, with no prose wrapped around it. See
154
+ [ci-state-records.md](./ci-state-records.md).
155
+
156
+ #### Writing rules
157
+
158
+ Enforce these on every write, autonomous or not:
159
+
160
+ - **One lesson per record.** Two takeaways are two keys.
161
+ - **Lead with the takeaway.** The `#` title is the instruction, not the topic —
162
+ "Pass `--node-modules-dir=none` to `deno check`", not "Notes on deno check".
163
+ - **Bold label, then one short paragraph.** No nesting past one list level, no
164
+ sub-headings; the whole record is read at a glance or not at all.
165
+ - **Fence every command or snippet**, with a language tag.
166
+ - **Budget ~1,500 characters.** A lesson longer than a screen is a document, and
167
+ a loop that injects documents crowds out the run it was meant to help. Cut the
168
+ narrative, keep the instruction.
169
+ - **No run residue** — no transcript excerpts, reasoning traces, session or
170
+ correlation IDs, timestamps, or "in this run I…" framing. A lesson is written
171
+ for the *next* run, which has none of that context.
172
+ - **No secrets or PII**, per the privacy pre-flight in the write step.
173
+
174
+ #### Worked example
175
+
176
+ ❌ **Don't** — a hidden header, a title that names a topic, and a run narrative:
177
+
178
+ ```markdown
179
+ <!-- meta: seen_count=1 status=active expires=2026-12-11 trigger-context="bash: line 5: dash0link: command not found" -->
180
+ # Notes on the Slack tool
181
+ **What failed:** In this run I called `slackSendMessage --args='{ … `dash0link` … }'`
182
+ and got `bash: line 5: dash0link: command not found`, then retried twice with
183
+ different escaping before it worked. Session 4b1c2efe, 2026-09-11.
184
+ ```
185
+
186
+ Everything before the title renders to nothing for a human; `seen_count` and
187
+ `expires` contradict the store the moment the lesson recurs; the title says what
188
+ the lesson is *about* rather than what to do; and the run residue is dead weight
189
+ in every future context window.
190
+
191
+ ✅ **Do** — the same lesson, all metadata in its own field:
192
+
193
+ ````markdown
194
+ # Send Slack messages via a heredoc, not inline `--args='…'`
195
+
196
+ **Applies when:** shelling out to `slackSendMessage` with text interpolated from
197
+ an external source (ticket titles, PR titles) that may contain backticks or apostrophes.
198
+
199
+ **What happened:** Inline `--args='{ … }'` failed with `bash: dash0link: command not found` —
200
+ the shell expanded backticks in the JSON, and an apostrophe closed the quoted argument early.
201
+ **Why:** Single quotes do not protect the string once the outer shell re-processes it.
202
+ **Do this instead:** Pass the JSON through a quoted heredoc, which needs no escaping:
203
+
204
+ ```bash
205
+ tools invoke slack.slackSendMessage --args="$(cat <<'ENDJSON'
206
+ { "text": "…" }
207
+ ENDJSON
208
+ )"
209
+ ```
210
+
211
+ **Promotion target:** the automation prompt's message-building step.
212
+ ````
213
+
214
+ Written with `tags: ["loop::<host>-lessons", "source::command-failure"]`,
215
+ `trigger: "command-failure"`, and `ttl_days: 90` — so the recurrence count, the
216
+ expiry, the owning host and the trigger are all queryable, and the body is
217
+ readable start to finish.
120
218
 
121
219
  ---
122
220
 
@@ -133,10 +231,10 @@ memory.search { q: "<keywords>", scopes: ["repo::{owner}/*", "global"], limit: 1
133
231
 
134
232
  Then:
135
233
 
136
- 1. Match each lesson's `trigger-context` against the current run. Consider only
137
- matches. **Skip any lesson whose `expires` is in the past** — treat it as
138
- stale.
139
- 2. Apply each matching *"What to do next time"* as a **consideration**, not a
234
+ 1. Match each lesson's **Applies when** line against the current run. Consider
235
+ only matches. Expired lessons do not come back — the store's own TTL drops
236
+ them, so the read never has to filter on a date in the prose.
237
+ 2. Apply each matching **Do this instead** line as a **consideration**, not a
140
238
  command — it biases the run unless it conflicts with the user's stated intent
141
239
  or a task-specific constraint. On conflict, the user's intent wins; surface it.
142
240
  3. On a `repo::` vs `global` collision, the `repo::` lesson wins (closer scope).
@@ -159,23 +257,31 @@ caught something, a near-miss, a guess that paid off. Not on smooth successes.
159
257
  memory.search { q: "<key words of the lesson>", scopes: ["repo::{owner}/{repo}", "global"], limit: 10 }
160
258
  ```
161
259
 
162
- 3. **Write** to the classified scope:
260
+ 3. **Write** to the classified scope, putting every fact in its own field and
261
+ nothing but prose in `value`:
163
262
 
164
263
  ```text
165
264
  memory.write {
166
- scope: "<global | repo::{owner}/{repo}>",
167
- key: "<host>-lessons::<slug>",
168
- value: "<the lesson body above>",
169
- tags: ["loop::<host>-lessons", "source::<trigger>"],
170
- trigger: "<stuck-loop | command-failure | gotcha | near-miss | assumption-wrong | paid-off | manual>"
265
+ scope: "<global | repo::{owner}/{repo}>",
266
+ key: "<host>-lessons::<slug>",
267
+ value: "<the markdown lesson body above — no hidden blocks>",
268
+ tags: ["loop::<host>-lessons", "source::<trigger>"], # + "status::structural" when it is
269
+ trigger: "<stuck-loop | command-failure | gotcha | near-miss | assumption-wrong | paid-off | manual>",
270
+ ttl_days: 90
171
271
  }
172
272
  ```
173
273
 
174
- Same `scope` + `key` overwrites in place. **A recurrence resolves to an UPDATE
175
- that increments `seen_count` by 1 and refreshes `expires`** — that is what makes
176
- recurrence countable and drives promotion. If a lesson you applied at the start
177
- of the run worked (the failure did not recur), still write the UPDATE:
178
- successful application is recurrence evidence.
274
+ `host` and `kind` are inferred from the `loop::<host>-lessons` tag; pass them
275
+ explicitly only when the tag does not carry them. Add the `origin_*` fields
276
+ when the run knows them.
277
+
278
+ Same `scope` + `key` overwrites in place. **A recurrence resolves to an UPDATE:
279
+ the store increments `seen_count` by 1 for you, and re-passing `ttl_days`
280
+ refreshes the expiry** — that is what makes recurrence countable and drives
281
+ promotion. Never hand-write a count into the body to track this; the column is
282
+ the only copy that stays true. If a lesson you applied at the start of the run
283
+ worked (the failure did not recur), still write the UPDATE: successful
284
+ application is recurrence evidence.
179
285
 
180
286
  The privacy pre-flight is never skipped, autonomous or not: a candidate lesson
181
287
  containing a secret, token, credential, or PII is **dropped, not written**. The
@@ -247,12 +353,141 @@ record shape are specified in `agents/shared/rules/comment-relevance-memory.md`.
247
353
 
248
354
  ---
249
355
 
356
+ ## Cross-bucket reads (targeted, read-only)
357
+
358
+ The default is strict: a loop reads **only its own bucket**, filtered by
359
+ `loop::<host>-lessons`. That isolation is deliberate — it keeps one loop's
360
+ lessons from drowning another's, and lets each read fire at its own cadence
361
+ against its own decision point. Wholesale "read every lesson this repo knows"
362
+ is an **anti-pattern**: it reintroduces exactly the noise the tag split exists
363
+ to prevent, and buries the matches that would have fired.
364
+
365
+ There is **one** shape of cross-bucket read that is safe and worth wiring: a host
366
+ reading **another host's Signal or Knowledge bucket, matched by a structural key,
367
+ strictly read-only**. Wire it only when all four hold:
368
+
369
+ 1. **The other bucket is keyed by something structural** — a `symbol@path`, a
370
+ file path, a stable fingerprint — never by prose. A structural key is what
371
+ makes a cross-host read meaningful: it matches the reader's own concrete work
372
+ (the files or symbols it is about to touch), not a vague topic.
373
+ 2. **The read is bounded to the reader's current work.** Match the other bucket's
374
+ keys against the paths / symbols this run will actually touch and ignore the
375
+ rest — never load the whole bucket as advice.
376
+ 3. **The reader treats it as advisory and re-verifies.** A cross-host fact can be
377
+ stale — it carries the *writer's* `verified_at_sha`, not the reader's. It
378
+ **raises care** (more coverage on a hotspot, design around a known invariant)
379
+ but never lowers a bar, skips a step, or suppresses a finding. An absent
380
+ record is never evidence of safety.
381
+ 4. **The reader never writes the other bucket.** Write ownership stays with the
382
+ one owning host; a second writer corrupts its provenance. Cross-host is a
383
+ **read** relationship only.
384
+
385
+ Do **not** cross-read another host's `loop::<host>-lessons`. Lessons are prose
386
+ "how to do better" advice tuned to that host's own decisions; they re-key on
387
+ rephrasing and carry no structural anchor to match against, so a cross-read of
388
+ them is the wholesale anti-pattern above. Only Signal / Knowledge buckets with
389
+ structural keys qualify.
390
+
391
+ LoreKit ships **one** standard instance of this pattern — the shared
392
+ `codebase-knowledge` bucket that every code-touching loop reads and writes. It is
393
+ the mechanism behind the automatic synergy below, and it is what makes a
394
+ structural key worth insisting on: a fixed name plus a `symbol@path` key is what
395
+ lets a loop wired by one person be consumed by a loop wired by another. It is
396
+ specified in full next.
397
+
398
+ ---
399
+
400
+ ## Shared codebase-knowledge (the standard cross-loop layer)
401
+
402
+ The cross-bucket read above becomes **automatic** through one bucket every LoreKit
403
+ loop shares by name: `codebase-knowledge`. This is the reason two skills wired
404
+ independently — by different people, in different sessions, in the same repo —
405
+ still compound: they read and write the *same* repo-scoped, structurally-keyed
406
+ record of what the codebase has taught every loop that touched it. A code-changing
407
+ loop plans and edits with that history in hand instead of blind; and because the
408
+ loops that consume it also feed it, the synergy appears for a user who wired a
409
+ single skill and nothing else.
410
+
411
+ ### The bucket
412
+
413
+ | Field | Value |
414
+ | --- | --- |
415
+ | **Tag** | `codebase-knowledge` |
416
+ | **Kind** | `signal` (a durable per-repo filter, read on every run that touches code) |
417
+ | **Scope** | `repo::{owner}/{repo}` — a codebase fact is repo-bound |
418
+ | **TTL** | ~90 days, refreshed on re-verification |
419
+ | **Keys** | `knowledge::<symbol>@<path>` — verified facts about one symbol (an invariant it holds, its consumer/dependent count, a defect it produced before); `hotspot::<path>` — per-file counters (`confirmed`, `regressed`, `missed`) |
420
+
421
+ The keys are **structural** (`symbol@path`, `path`) on purpose: a key survives a
422
+ rename of the *finding* but not a rename of the *code*, which is exactly the
423
+ sensitivity that lets a different loop match it against the files it is about to
424
+ touch. Set `kind: signal` and `host` explicitly on every write — LoreKit infers
425
+ them only from a `loop::` tag, and this bucket is not tagged that way.
426
+
427
+ ### Read side — automatic for any code-touching host
428
+
429
+ Wire it at the host's **plan/apply seam** — the moment it has the concrete
430
+ file/symbol list it will change (a plan's File Changes list, an apply pack, a
431
+ fix's target file):
432
+
433
+ ```text
434
+ memory.list { scope: "repo::{owner}/{repo}", tags: ["codebase-knowledge"], limit: 100 }
435
+ # keep only hotspot::<path> / knowledge::<symbol>@<path> whose <path> (and <symbol>)
436
+ # this run will actually touch. Apply as PLANNING INPUTS: raise coverage on a
437
+ # hotspot, design around a known invariant / consumer count. Advisory and
438
+ # re-verified against the code — never a reason to skip a step or suppress a finding.
439
+ ```
440
+
441
+ This is the read-side contract from [Cross-bucket reads](#cross-bucket-reads-targeted-read-only)
442
+ made concrete: structural match, bounded to this run, advisory, an absent record
443
+ never evidence of safety.
444
+
445
+ ### Write side — how the layer fills, and why many writers stay safe
446
+
447
+ A host that **verifies** a structural fact contributes it back, so the next loop
448
+ reads it. This is what makes the synergy automatic even for a user with one skill
449
+ and no dedicated reviewer: the loops that consume the layer also feed it.
450
+ Multi-writer is safe **only** behind this write contract — bake in every bullet,
451
+ or do not wire the write:
452
+
453
+ - **Structural key from a real symbol/path list**, never composed from prose. A
454
+ prose key accumulates nothing and no reader can match it.
455
+ - **`verified_at_sha` on every fact** — the HEAD this run verified it at. It is the
456
+ whole mechanism the next reader uses to decide "fact stands" vs "re-verify"; an
457
+ absent or stale SHA makes the fact permanently unverifiable, and it is dropped.
458
+ - **`source_agent` stamped** — which host verified it. Together with
459
+ `verified_at_sha` this is what makes many writers safe: a reader sees who
460
+ verified what, and when, so no writer silently overwrites another's provenance.
461
+ - **Only what THIS run actually verified**, grounded in the code — never a guess,
462
+ and never a value about a person or a telemetry reading. A fact about code,
463
+ keyed to code.
464
+ - **Merge, never clobber.** Read the existing record first; append to `history[]`
465
+ or increment counters (each capped) and carry the rest through unchanged. A
466
+ clobbered counter is indistinguishable from a first write.
467
+ - **Raise care, never suppress.** These records only raise priority/coverage on a
468
+ file or symbol. They never lower a bar or silence a finding, and an absent record
469
+ is never evidence of safety. Suppression, if a host needs it, is a different
470
+ bucket behind verification (the Signal in the reconcile flow above).
471
+ - **Explicit `kind: signal` + `host`, a TTL, and the privacy pre-flight** — as
472
+ every write in this skill.
473
+
474
+ Because the name is fixed, the read is the same call in every host, and the write
475
+ follows one contract, **any two LoreKit-wired loops in the same repo compound
476
+ automatically** — which is the whole reason to standardize the name instead of
477
+ letting each host invent its own. The reference ecosystem is `agent-skills`: the
478
+ `pr-reviewer` agent is the primary writer (it verifies symbol facts and file
479
+ hotspots during review), and every code-changing host — `aw`, `implement-suggestion`,
480
+ `fix-bug`, `ci-auto-fix` — reads the layer at its plan/apply seam.
481
+
482
+ ---
483
+
250
484
  ## Promotion (fast → slow)
251
485
 
252
486
  After a read or write, a lesson is **promotion-eligible** when either:
253
487
 
254
- - `seen_count >= 3` — the same failure recurred across at least three runs, or
255
- - it is tagged `status=structural` because it reflects a design gap, not a
488
+ - `seen_count >= 3` — the same failure recurred across at least three runs (read
489
+ the store's column, never a number written into the body), or
490
+ - it carries the `status::structural` tag because it reflects a design gap, not a
256
491
  one-off.
257
492
 
258
493
  For an eligible lesson, **surface a one-line suggestion — never act silently**:
@@ -264,8 +499,8 @@ The promotion target follows the lesson's scope: a `global` lesson hardens the
264
499
  **host's own source** (every user of the host benefits); a `repo::` lesson
265
500
  hardens the **repo's own rules / docs** (every teammate in that repo benefits).
266
501
  Promotion is a normal, human-reviewed edit — LoreKit does not apply it. After a
267
- successful promotion, write an UPDATE setting `status=promoted` so the lesson
268
- stops re-suggesting and stands as an audit trail of why the rule exists.
502
+ successful promotion, write an UPDATE adding the `status::promoted` tag so the
503
+ lesson stops re-suggesting and stands as an audit trail of why the rule exists.
269
504
 
270
505
  A recurring lesson can be promoted a second time, past the prose rule above,
271
506
  into a **compiled invariant** — a declarative, mechanically-checked assertion
@@ -290,9 +525,10 @@ guards are what make the loop safe:
290
525
  run; it can never silently disable a gate, skip a step, or change a limit. The
291
526
  only path from a lesson to changed behavior is the human-reviewed slow tier.
292
527
  2. **Recurrence gates promotion, not a single run** (`seen_count >= 3`, or an
293
- explicit `status=structural` marker in the lesson's `meta:` comment).
294
- 3. **Every lesson expires** (default ~90 days from last sighting; the read step
295
- ignores expired lessons, so stale beliefs decay instead of entrenching).
528
+ explicit `status::structural` tag on the lesson).
529
+ 3. **Every lesson expires** — `ttl_days: 90` on the write, refreshed on each
530
+ recurrence, so stale beliefs decay instead of entrenching. The store enforces
531
+ this; an expiry stated only in prose is decoration.
296
532
  4. **Contradiction is surfaced, not silently overwritten** — the dedup search
297
533
  finds the prior lesson; a genuine reversal is a reviewed decision.
298
534
  5. **The privacy pre-flight is never bypassed** — secrets / PII are dropped, not
@@ -306,16 +542,21 @@ To add a loop to a host called `<host>`:
306
542
 
307
543
  - [ ] Pick the bucket: tag `loop::<host>-lessons`, key `<host>-lessons::<slug>`.
308
544
  - [ ] Add the **read step** at the start of the host's run (narrow-to-broad
309
- `memory.list` filtered by the tag; apply matches as considerations; skip
310
- expired).
545
+ `memory.list` filtered by the tag; apply matches as considerations).
311
546
  - [ ] Add the **write step** at the host's existing failure / end-of-run points
312
- (classify scope, `memory.search` to dedup, `memory.write`). No new
313
- reflection stage — hook the points the host already detects.
547
+ (classify scope, `memory.search` to dedup, `memory.write` with `ttl_days`).
548
+ No new reflection stage — hook the points the host already detects.
549
+ - [ ] State the **body contract** in the host's own write step: markdown to the
550
+ shape above, no hidden blocks, every store-backed fact in its own field.
314
551
  - [ ] Add the **promotion suggestion** when a read/written lesson hits
315
- `seen_count >= 3` or `status=structural`.
552
+ `seen_count >= 3` or carries `status::structural`.
316
553
  - [ ] State the **entrenchment guards** so a future maintainer does not "optimize
317
554
  them away".
318
555
  - [ ] Confirm the loop **degrades silently** when `memory.*` is not connected.
556
+ - [ ] If the host **touches code**, wire the **[codebase-knowledge](#shared-codebase-knowledge-the-standard-cross-loop-layer)
557
+ read** at its plan/apply seam (match `hotspot::<path>` /
558
+ `knowledge::<symbol>@<path>` to the files it will change). If it **verifies**
559
+ a structural fact, wire the **write** behind that section's contract.
319
560
 
320
561
  ---
321
562
 
@@ -14,15 +14,16 @@
14
14
  //
15
15
  // Criteria (pure scoring in `../shared/candidates-pure.mjs`):
16
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
17
+ // (default 3), OR any member already declares a non-"active" status —
18
+ // a `status::<value>` tag, or a legacy `<!-- meta: ... status=... -->`
19
+ // comment for lessons written before tags were the convention
19
20
  // - ranked by (summed seen_count × distinct scopes), descending
20
21
  //
21
22
  // 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.
23
+ // - classify an applicability signal into a glob/command/error-shape. The raw
24
+ // string (a lesson's `**Applies when:**` line, or a legacy meta comment's
25
+ // `trigger-context`) is printed verbatim, never interpreted — "parses into
26
+ // a detectable trigger" is the human step the compile pipeline protects.
26
27
  // - check `compiled_to`. No such field exists yet (no schema, no server
27
28
  // support — see the kickoff's Open Questions), so a candidate already
28
29
  // compiled into an obligations-map.mjs entry can still surface here. A
@@ -150,7 +151,13 @@ function buildCandidates(entries, { threshold, minSeenCount }) {
150
151
  ...cl,
151
152
  members: cl.members.map((m) => {
152
153
  const raw = byAddress.get(`${m.scope}::${m.key}`);
153
- return { scope: m.scope, key: m.key, seenCount: seenCountOf(raw), value: raw?.value ?? '' };
154
+ return {
155
+ scope: m.scope,
156
+ key: m.key,
157
+ seenCount: seenCountOf(raw),
158
+ value: raw?.value ?? '',
159
+ tags: Array.isArray(raw?.tags) ? raw.tags : [],
160
+ };
154
161
  }),
155
162
  }));
156
163
  return rankCandidates(clusters, { minSeenCount, resolveClass: resolveRecurrenceClass });
@@ -221,7 +228,7 @@ async function candidates(args) {
221
228
  heading('LoreKit invariants candidates');
222
229
  log(` project: ${c.dim(root)}`);
223
230
  log(` scopes: ${scopes.join(' → ')}`);
224
- log(` ${c.dim(`criteria: summed seen_count >= ${minSeenCount}, or a member's meta status is non-"active"`)}`);
231
+ log(` ${c.dim(`criteria: summed seen_count >= ${minSeenCount}, or a member's status is non-"active"`)}`);
225
232
 
226
233
  if (offlineSection.available && offlineSection.popCapped) {
227
234
  log(` ${c.yellow('!')} population cap (${POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
@@ -281,8 +288,8 @@ function renderSection(header, section) {
281
288
  }
282
289
  for (const m of cand.members) {
283
290
  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'])}`);
291
+ if (m.status) fields.push(`status=${m.status}`);
292
+ if (m.appliesWhen) fields.push(`applies-when=${JSON.stringify(m.appliesWhen)}`);
286
293
  log(` ${c.cyan('-')} ${m.scope}::${m.key} ${c.dim(`(${fields.join(', ')})`)}`);
287
294
  }
288
295
  }