@lorekit/cli 1.26.1 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,8 @@ without needing a marketplace:
53
53
  3. **Hooks** — the *deterministic* layer: lessons injected on every
54
54
  `SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
55
55
  look **relevant to that failure** ("you've hit this before") plus a nudge to
56
- record the fix, and a retrospective nudge on `Stop`. These fire the shared
56
+ record the fix, and a retrospective nudge on `Stop` by default only when the
57
+ session actually hit friction (`hooks.stop`). These fire the shared
57
58
  `lorekit hook` engine and are merged into `settings.json` (existing hooks
58
59
  preserved).
59
60
 
@@ -320,6 +321,7 @@ lorekit link global # the Explorer filtered to global scop
320
321
  lorekit link repo::owner/repo prefer-guards # open one lesson's detail sheet
321
322
  lorekit link global::prefer-guards --json # { url, surface, base, params }
322
323
  lorekit url --q "flaky test" --owner personal # search + ownership filter
324
+ lorekit link global --tags "perf,ci" # Explorer filtered to labels
323
325
  ```
324
326
 
325
327
  With no arguments it links to the cwd's **most-specific scope** ("share what I'm
@@ -330,8 +332,9 @@ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
330
332
  sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
331
333
  reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
332
334
  filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
333
- (search), `--owner <all|personal|orgId>`, `--range`/`--from`/`--to`, `--archived`,
334
- `--view <scope|time>`.
335
+ (search), `--owner <all|personal|orgId>`, `--tags <a,b,c>` (label filter, AND
336
+ across labels; comma-separated or a JSON array), `--range`/`--from`/`--to`,
337
+ `--archived`, `--view <scope|time>`.
335
338
 
336
339
  Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
337
340
  how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
@@ -519,6 +522,18 @@ Both files share this schema — all fields optional:
519
522
  // suppress specific hook events; union across layers
520
523
  // values: "SessionStart" | "PostToolUseFailure" | "Stop"
521
524
 
525
+ "hooks.stop": "friction",
526
+ // gate the end-of-turn retrospective nudge:
527
+ // "friction" (default) — only nudge once/session when the
528
+ // session hit friction (a failed tool call or a stuck
529
+ // retry loop, read from the transcript); silent otherwise
530
+ // "always" — nudge once per session regardless
531
+ // "off" — never (same effect as disabling Stop)
532
+ // repo wins over user
533
+ // (friction is detectable only on Claude Code, which exposes a
534
+ // transcript; on Cursor/Codex there is none, so "friction"
535
+ // falls back to firing so no lesson is silently lost)
536
+
522
537
  "hooks.adapter": "claude",
523
538
  // explicit adapter when auto-detection is ambiguous
524
539
  // values: "claude" | "cursor" | "codex"
package/bin/lorekit.mjs CHANGED
@@ -85,8 +85,8 @@ ${c.bold('Commands')}
85
85
  link (url) Print a shareable dashboard deep-link URL for the current context,
86
86
  a scope, or a specific lesson (opens its detail sheet). No args
87
87
  links to the cwd's most-specific scope. Filter flags mirror the
88
- Explorer (--q / --owner / --range / --archived / --view); --base or
89
- LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
88
+ Explorer (--q / --owner / --tags / --range / --archived / --view);
89
+ --base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
90
90
  lorekit link | pbcopy.
91
91
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
92
92
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
@@ -506,6 +506,7 @@ ${c.bold('Options')}
506
506
  --scope <scope> Scope to link to (when no positional scope is given)
507
507
  --q <text> Pre-fill the Explorer search box
508
508
  --owner <o> Ownership filter: all | personal | <orgId>
509
+ --tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
509
510
  --range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
510
511
  --from <date> Range start (shorthand for --range)
511
512
  --to <date> Range end (shorthand for --range)
@@ -521,6 +522,7 @@ ${c.bold('Examples')}
521
522
  npx @lorekit/cli link repo::owner/repo prefer-guards # open one lesson's detail sheet
522
523
  npx @lorekit/cli link global::prefer-guards --json # { url, surface, base, params }
523
524
  npx @lorekit/cli url --q "flaky test" --owner personal # search + ownership filter
525
+ npx @lorekit/cli link global --tags "perf,ci" # Explorer filtered to labels
524
526
  `,
525
527
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
526
528
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.26.1",
3
+ "version": "1.28.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,9 @@ export const claude = {
42
42
  toolInput: input.tool_input || null,
43
43
  toolResponse: input.tool_response || null,
44
44
  event: input.hook_event_name || null,
45
+ // Path to the session JSONL (present on Stop/SubagentStop) — read by the
46
+ // friction-gated retrospective to decide whether the session is worth a nudge.
47
+ transcriptPath: input.transcript_path || null,
45
48
  };
46
49
  },
47
50
 
package/src/control.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // scope.defaults — map of scope-prefix → { tags } applied to every matching write
6
6
  // tags.default — array of tags appended to every write (both layers merged)
7
7
  // hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
8
+ // hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
8
9
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
9
10
  //
10
11
  // Two layers of config, two kinds of statement:
@@ -39,6 +40,19 @@ export function normalizeMode(v) {
39
40
  return null;
40
41
  }
41
42
 
43
+ // Stop-hook behaviour: `friction` (default — only nudge on detected friction),
44
+ // `always` (nudge once per session regardless), or `off` (never). Accepts a few
45
+ // friendly spellings so config stays forgiving.
46
+ export const STOP_MODES = ['friction', 'always', 'off'];
47
+ export function normalizeStopMode(v) {
48
+ if (typeof v !== 'string') return null;
49
+ const s = v.trim().toLowerCase();
50
+ if (['off', 'none', 'false', 'disabled', 'never'].includes(s)) return 'off';
51
+ if (['always', 'all', 'on', 'true', 'every'].includes(s)) return 'always';
52
+ if (['friction', 'smart', 'auto'].includes(s)) return 'friction';
53
+ return null;
54
+ }
55
+
42
56
  function asList(v) {
43
57
  if (Array.isArray(v)) return v;
44
58
  if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
@@ -131,6 +145,13 @@ export function resolveControl({
131
145
  ...asList(userConfig['hooks.disabled']),
132
146
  ]);
133
147
 
148
+ // `hooks.stop` — repo layer wins over user layer, default `friction`. Gates the
149
+ // end-of-turn retrospective: friction-only (default), always, or off.
150
+ const hooksStop =
151
+ normalizeStopMode(repoConfig['hooks.stop']) ||
152
+ normalizeStopMode(userConfig['hooks.stop']) ||
153
+ 'friction';
154
+
134
155
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
135
156
  const hooksAdapter =
136
157
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -167,6 +188,7 @@ export function resolveControl({
167
188
  tagsDefault,
168
189
  scopeDefaults,
169
190
  hooksDisabled,
191
+ hooksStop,
170
192
  hooksAdapter,
171
193
  hooksInstructions,
172
194
  };
@@ -0,0 +1,105 @@
1
+ // Friction detection for the Stop (retrospective) hook.
2
+ //
3
+ // The retrospective nudge has no side effect of its own — it exists only to
4
+ // prompt an end-of-turn reflection. Firing it on *every* session is noise: a
5
+ // clean "regenerate four icons" turn had nothing worth remembering. So when
6
+ // `hooks.stop` is `friction` (the default) we read the session transcript and
7
+ // only surface the nudge when the session actually hit friction.
8
+ //
9
+ // Signals are deliberately conservative (a false positive nudges needlessly):
10
+ // - `failure` — a tool call reported an error this session (is_error).
11
+ // - `stuck-loop` — the same tool+input ran >= STUCK_LOOP_THRESHOLD times
12
+ // (a retry loop / repeated dead end).
13
+ //
14
+ // The pure `detectFriction` works on the raw transcript text so it is unit
15
+ // testable with fixture strings; `readSessionFriction` is the thin IO wrapper.
16
+ // Both are best-effort: anything unreadable degrades to `friction: null`
17
+ // ("unknown"), never a throw — a memory hook must never break the host.
18
+ import fs from 'node:fs';
19
+
20
+ export const STUCK_LOOP_THRESHOLD = 3;
21
+
22
+ // Reason codes, surfaced in the nudge so the reflection is grounded ("this
23
+ // session hit a failed tool call") rather than a generic prompt.
24
+ export const FRICTION_FAILURE = 'failure';
25
+ export const FRICTION_STUCK_LOOP = 'stuck-loop';
26
+
27
+ // Pure: scan a Claude Code JSONL transcript for friction signals. (Only Claude
28
+ // Code surfaces a transcript path to the hook; the Cursor/Codex adapters don't,
29
+ // so friction there is always `null` — see readSessionFriction / shouldRetrospect.)
30
+ // Returns { friction: boolean, reasons: string[] }. Never throws.
31
+ export function detectFriction(transcriptText, { stuckLoopThreshold = STUCK_LOOP_THRESHOLD } = {}) {
32
+ const reasons = new Set();
33
+ if (typeof transcriptText !== 'string' || transcriptText.length === 0) {
34
+ return { friction: false, reasons: [] };
35
+ }
36
+
37
+ const callCounts = new Map(); // "name input" -> count
38
+ for (const line of transcriptText.split('\n')) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed) continue;
41
+ let entry;
42
+ try {
43
+ entry = JSON.parse(trimmed);
44
+ } catch {
45
+ continue; // tolerate non-JSON / partial lines
46
+ }
47
+ const content = entry && entry.message && Array.isArray(entry.message.content)
48
+ ? entry.message.content
49
+ : null;
50
+ if (!content) continue;
51
+
52
+ for (const item of content) {
53
+ if (!item || typeof item !== 'object') continue;
54
+ if (item.type === 'tool_result' && item.is_error === true) {
55
+ reasons.add(FRICTION_FAILURE);
56
+ } else if (item.type === 'tool_use' && typeof item.name === 'string') {
57
+ // Serialize the input so identical retries collide; JSON.stringify is
58
+ // enough for the loop signal (key order is stable within one session).
59
+ let inputKey = '';
60
+ try {
61
+ inputKey = JSON.stringify(item.input ?? null);
62
+ } catch {
63
+ inputKey = '';
64
+ }
65
+ const key = `${item.name} ${inputKey}`;
66
+ const next = (callCounts.get(key) || 0) + 1;
67
+ callCounts.set(key, next);
68
+ if (next >= stuckLoopThreshold) reasons.add(FRICTION_STUCK_LOOP);
69
+ }
70
+ }
71
+ }
72
+
73
+ return { friction: reasons.size > 0, reasons: [...reasons] };
74
+ }
75
+
76
+ // IO wrapper: read the transcript at `transcriptPath` and detect friction.
77
+ // Returns { friction: null, reasons: [] } when the path is absent or unreadable
78
+ // — "unknown", which the caller treats conservatively (does not swallow the
79
+ // nudge on platforms/turns where we cannot measure).
80
+ export function readSessionFriction(transcriptPath, opts = {}) {
81
+ if (!transcriptPath || typeof transcriptPath !== 'string') {
82
+ return { friction: null, reasons: [] };
83
+ }
84
+ let text;
85
+ try {
86
+ text = fs.readFileSync(transcriptPath, 'utf8');
87
+ } catch {
88
+ return { friction: null, reasons: [] };
89
+ }
90
+ return detectFriction(text, opts);
91
+ }
92
+
93
+ // Pure gating decision for the retrospective hook, given the resolved
94
+ // `hooks.stop` mode and the detected friction (true | false | null=unknown):
95
+ // - off → never
96
+ // - always → always
97
+ // - friction → only on positively-detected friction; `null` (undetectable,
98
+ // e.g. no transcript on Cursor/Codex) falls back to firing so no
99
+ // lesson is silently lost where we cannot measure.
100
+ export function shouldRetrospect(mode, friction) {
101
+ if (mode === 'off') return false;
102
+ if (mode === 'always') return true;
103
+ // friction mode
104
+ return friction !== false;
105
+ }
@@ -13,6 +13,7 @@ import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
13
13
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
14
14
  // from the command-line links.
15
15
  import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
16
+ import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
16
17
 
17
18
  const MAX_LESSONS = 15;
18
19
  // Cap on lessons injected on a failure — a small, focused "you've seen this
@@ -161,7 +162,8 @@ export function formatRelevantLessons(lessons) {
161
162
 
162
163
  // Build a tags hint string from config-resolved tags and scope defaults. Returns
163
164
  // "" when there are no configured tags (no hint appended to the nudge).
164
- function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
165
+ function tagsHint(writeScope, control) {
166
+ const { tagsDefault = [], scopeDefaults = null } = control || {};
165
167
  const tags = [...tagsDefault];
166
168
  if (scopeDefaults) {
167
169
  for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
@@ -179,30 +181,41 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
179
181
  return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
180
182
  }
181
183
 
182
- // The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
183
- // Exported so tests can assert the URL shape without re-deriving the encoding.
184
- // Delegates to the shared `loreScopeUrl` so the scope param is JSON-encoded the
185
- // way the dashboard reads it — the previous raw `?scope=${scope}` fell through
186
- // `useUrlState`'s `JSON.parse` and silently filtered to ALL scopes.
187
- export function loreUrl(writeScope) {
188
- return loreScopeUrl(writeScope);
184
+ // One-line phrases for the detected friction reason codes (see core/friction.mjs),
185
+ // so the nudge names what happened instead of a generic prompt.
186
+ const REASON_PHRASES = {
187
+ [FRICTION_FAILURE]: 'a failed tool call',
188
+ [FRICTION_STUCK_LOOP]: 'a repeated retry',
189
+ };
190
+
191
+ // Join detected reason codes into a readable clause ("a failed tool call and a
192
+ // repeated retry"). Empty/unknown reasons → "" (caller uses the generic prompt).
193
+ function describeReasons(reasons) {
194
+ const phrases = (Array.isArray(reasons) ? reasons : [])
195
+ .map((r) => REASON_PHRASES[r])
196
+ .filter(Boolean);
197
+ if (phrases.length === 0) return '';
198
+ if (phrases.length === 1) return phrases[0];
199
+ return `${phrases.slice(0, -1).join(', ')} and ${phrases[phrases.length - 1]}`;
189
200
  }
190
201
 
191
202
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
192
203
  // `control` is the resolved control object (optional) — carries tagsDefault and
193
- // scopeDefaults when the repo/user config defines them.
194
- export function retrospectiveNudge(scope, control) {
204
+ // scopeDefaults when the repo/user config defines them. `opts.reasons` is the
205
+ // detected friction reason codes (from core/friction.mjs) when `hooks.stop` is
206
+ // `friction`; when present the nudge names them so the reflection is grounded.
207
+ // Kept to a single line — the lore deep-link lives on the write CONFIRMATION,
208
+ // which is where a link is actually actionable.
209
+ export function retrospectiveNudge(scope, control, { reasons = [] } = {}) {
195
210
  const writeScope = scope.repoScope || 'global';
196
211
  const hint = tagsHint(writeScope, control);
197
212
  const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
198
213
  ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
199
- const url = loreUrl(writeScope);
200
- return (
201
- `LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
202
- `failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
203
- `as an observation; else skip.${hint}${instruction}\n` +
204
- `View lore: ${url}`
205
- );
214
+ const detected = describeReasons(reasons);
215
+ const lead = detected
216
+ ? `LoreKit: this session hit ${detected} — a lesson worth saving?`
217
+ : `LoreKit: any friction worth remembering (stuck loop, repeat failure, gotcha, wrong assumption)?`;
218
+ return `${lead} memory.write to ${writeScope}; else skip.${hint}${instruction}`;
206
219
  }
207
220
 
208
221
  // Terse confirmation emitted via PostToolUse when a memory.write succeeded.
@@ -11,17 +11,33 @@ function stateDir() {
11
11
  return base;
12
12
  }
13
13
 
14
+ function markerPath(sessionId, tag) {
15
+ const hash = crypto.createHash('sha256').update(`${sessionId}:${tag}`).digest('hex').slice(0, 16);
16
+ return path.join(stateDir(), `${hash}.seen`);
17
+ }
18
+
14
19
  // Returns true the FIRST time called for a given (sessionId, tag), false after.
15
20
  // Missing sessionId → always true (cannot throttle without a key).
16
21
  export function firstTimeThisSession(sessionId, tag) {
17
22
  if (!sessionId) return true;
18
- const hash = crypto.createHash('sha256').update(`${sessionId}:${tag}`).digest('hex').slice(0, 16);
19
- const marker = path.join(stateDir(), `${hash}.seen`);
20
23
  try {
21
24
  // wx fails if the file already exists → not the first time.
22
- fs.writeFileSync(marker, '', { flag: 'wx' });
25
+ fs.writeFileSync(markerPath(sessionId, tag), '', { flag: 'wx' });
23
26
  return true;
24
27
  } catch {
25
28
  return false;
26
29
  }
27
30
  }
31
+
32
+ // Read-only peek: has a (sessionId, tag) marker already been written this
33
+ // session? Unlike `firstTimeThisSession` this NEVER creates the marker, so a
34
+ // caller can ask "did the failure hook already fire?" without consuming a
35
+ // throttle it does not own. Missing sessionId (or any IO error) → false.
36
+ export function sessionMarkerExists(sessionId, tag) {
37
+ if (!sessionId) return false;
38
+ try {
39
+ return fs.existsSync(markerPath(sessionId, tag));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
@@ -29,14 +29,17 @@ export const LORE_PARAM_DEFAULTS = {
29
29
  q: '', // string search query
30
30
  range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
31
31
  owner: 'all', // 'all' | 'personal' | { orgId }
32
+ tags: [], // string[] — label filter (AND across labels); [] means "no filter"
32
33
  view: 'scope', // 'scope' | 'time'
33
34
  archived: false, // boolean
34
35
  lesson: null, // { scope, key } | null — opens the detail sheet
35
36
  };
36
37
 
37
38
  // A stable, readable param order (also makes URLs deterministic for tests).
38
- // `scope` precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
39
- const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'view', 'archived', 'lesson'];
39
+ // Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
40
+ // param last), so `tags` sits between `owner` and `view`. `scope` precedes
41
+ // `lesson` so a lesson link reads `?scope=…&lesson=…`.
42
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'tags', 'view', 'archived', 'lesson'];
40
43
 
41
44
  // Strip trailing slashes from a base URL, falling back to the default when the
42
45
  // input is empty/absent. Pure.
@@ -154,6 +157,44 @@ export function parseViewArg(view) {
154
157
  return view === 'time' ? 'time' : 'scope';
155
158
  }
156
159
 
160
+ // Coerce the `--tags` flag to a normalized `string[]` label filter, mirroring the
161
+ // web app's `normalizeTags` (`packages/web/src/lib/tag-filter.ts`): trim each
162
+ // entry, drop empties, de-duplicate, preserve first-seen order. Accepts either a
163
+ // JSON array string (`'["perf","ci"]'`) or the friendlier comma-separated form
164
+ // (`'perf, ci'`); a malformed JSON array falls back to comma-splitting rather
165
+ // than throwing. Returns `[]` for absent/empty input (the default → omitted from
166
+ // the URL). Pure.
167
+ export function parseTagsArg(tags) {
168
+ if (Array.isArray(tags)) return normalizeTagList(tags);
169
+ if (typeof tags !== 'string' || !tags.trim()) return [];
170
+ const s = tags.trim();
171
+ if (s.startsWith('[')) {
172
+ try {
173
+ const parsed = JSON.parse(s);
174
+ if (Array.isArray(parsed)) return normalizeTagList(parsed);
175
+ } catch {
176
+ /* malformed JSON array → fall through to comma-splitting */
177
+ }
178
+ }
179
+ return normalizeTagList(s.split(','));
180
+ }
181
+
182
+ // Trim, drop non-string/empty entries, and de-duplicate preserving order. The
183
+ // CLI-side twin of the web's `normalizeTags`; kept inline to keep this module
184
+ // zero-import. Pure.
185
+ function normalizeTagList(list) {
186
+ const seen = new Set();
187
+ const out = [];
188
+ for (const item of list) {
189
+ if (typeof item !== 'string') continue;
190
+ const t = item.trim();
191
+ if (!t || seen.has(t)) continue;
192
+ seen.add(t);
193
+ out.push(t);
194
+ }
195
+ return out;
196
+ }
197
+
157
198
  // Coerce the date-range flags to a `{ from, to }` DateRange or null. `--range`
158
199
  // (a JSON object string) wins; else `--from`/`--to` shorthand builds one (both
159
200
  // keys always present so the shape matches the app's DateRange). A malformed
package/src/hook.mjs CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  writeConfirmation,
19
19
  } from './core/lessons.mjs';
20
20
  import { isFailure } from './core/failure.mjs';
21
- import { firstTimeThisSession } from './core/state.mjs';
21
+ import { readSessionFriction, shouldRetrospect, FRICTION_FAILURE } from './core/friction.mjs';
22
+ import { firstTimeThisSession, sessionMarkerExists } from './core/state.mjs';
22
23
  import { recordFixture } from './core/record.mjs';
23
24
  import { claude } from './adapters/claude.mjs';
24
25
  import { cursor } from './adapters/cursor.mjs';
@@ -160,8 +161,34 @@ async function run(args) {
160
161
  }
161
162
 
162
163
  if (intent === 'retrospective') {
164
+ // `hooks.stop` gates the retrospective: `friction` (default) reads the
165
+ // session transcript and only nudges when it hit real friction; `always`
166
+ // keeps the once-per-session nudge; `off` is silent. The friction read is
167
+ // side-effect-free and happens BEFORE the once-per-session throttle is
168
+ // consumed, so a clean early turn stays silent without burning the marker —
169
+ // a later turn that does hit friction can still fire (once).
170
+ const stopMode = control.hooksStop || 'friction';
171
+ let reasons = [];
172
+ let friction = null;
173
+ if (stopMode === 'friction') {
174
+ ({ friction, reasons } = readSessionFriction(parsed.transcriptPath));
175
+ // The transcript is written ASYNCHRONOUSLY and may lag the current turn
176
+ // (Claude Code hooks reference, `transcript_path`), so a Stop fired right
177
+ // after a failing tool call can read a positively-clean `false`. The
178
+ // PostToolUseFailure hook already left a session-keyed marker when it
179
+ // fired, which is a transcript-independent witness of the exact same
180
+ // predicate `detectFriction` calls FRICTION_FAILURE (any errored tool
181
+ // result anywhere this session). Peek at it (read-only — never consume
182
+ // the marker) and upgrade. This covers the `failure` signal ONLY;
183
+ // `stuck-loop` remains transcript-only and is still exposed to the lag.
184
+ if (friction === false && sessionMarkerExists(parsed.sessionId, 'failure')) {
185
+ friction = true;
186
+ reasons = [FRICTION_FAILURE];
187
+ }
188
+ }
189
+ if (!shouldRetrospect(stopMode, friction)) return 0;
163
190
  if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
164
- emit(retrospectiveNudge(scope, control));
191
+ emit(retrospectiveNudge(scope, control, { reasons }));
165
192
  return 0;
166
193
  }
167
194
 
package/src/link.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  parseOwnerArg,
26
26
  parseViewArg,
27
27
  parseRangeArg,
28
+ parseTagsArg,
28
29
  resolveScopeArg,
29
30
  surfaceFor,
30
31
  } from './deeplink-pure.mjs';
@@ -66,6 +67,7 @@ export async function link(args) {
66
67
  const owner = parseOwnerArg(args.owner);
67
68
  const view = parseViewArg(args.view);
68
69
  const range = parseRangeArg(args);
70
+ const tags = parseTagsArg(args.tags);
69
71
  const archived = Boolean(args.archived);
70
72
 
71
73
  const gaveAnyInput =
@@ -75,6 +77,7 @@ export async function link(args) {
75
77
  owner !== 'all' ||
76
78
  view !== 'scope' ||
77
79
  range !== null ||
80
+ tags.length > 0 ||
78
81
  archived;
79
82
 
80
83
  // Bare `lorekit link` (no scope, no lesson, no filters) → the cwd's
@@ -90,6 +93,7 @@ export async function link(args) {
90
93
  if (key) params.lesson = { scope, key };
91
94
  if (q) params.q = q;
92
95
  if (owner !== 'all') params.owner = owner;
96
+ if (tags.length) params.tags = tags;
93
97
  if (view !== 'scope') params.view = view;
94
98
  if (range !== null) params.range = range;
95
99
  if (archived) params.archived = true;