@lorekit/cli 1.11.0 → 1.12.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 +18 -15
- package/package.json +1 -1
- package/src/core/lessons.mjs +153 -9
- package/src/hook.mjs +25 -2
- package/src/lessons-pure.mjs +65 -0
- package/src/lessons-view.mjs +0 -0
- package/src/scope.mjs +5 -1
- package/src/tree.mjs +13 -11
package/README.md
CHANGED
|
@@ -51,9 +51,11 @@ without needing a marketplace:
|
|
|
51
51
|
2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
|
|
52
52
|
MCP config (preserving any other servers).
|
|
53
53
|
3. **Hooks** — the *deterministic* layer: lessons injected on every
|
|
54
|
-
`SessionStart`,
|
|
55
|
-
|
|
56
|
-
and
|
|
54
|
+
`SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
|
|
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
|
|
57
|
+
`lorekit hook` engine and are merged into `settings.json` (existing hooks
|
|
58
|
+
preserved).
|
|
57
59
|
|
|
58
60
|
It first asks **where** to install:
|
|
59
61
|
|
|
@@ -212,10 +214,10 @@ rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
|
212
214
|
|
|
213
215
|
### `lorekit tree` (alias `resolve`)
|
|
214
216
|
|
|
215
|
-
Show the scopes the hooks actually **inject** — branch → repo →
|
|
216
|
-
precedence order (most-specific first) — as a resolution hierarchy,
|
|
217
|
-
any key present at more than one scope which scope's lesson **wins**
|
|
218
|
-
**shadowed**:
|
|
217
|
+
Show the scopes the hooks actually **inject** — project → branch → repo →
|
|
218
|
+
global, in precedence order (most-specific first) — as a resolution hierarchy,
|
|
219
|
+
and mark for any key present at more than one scope which scope's lesson **wins**
|
|
220
|
+
and which are **shadowed**:
|
|
219
221
|
|
|
220
222
|
```bash
|
|
221
223
|
lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
|
|
@@ -224,12 +226,12 @@ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[]
|
|
|
224
226
|
```
|
|
225
227
|
|
|
226
228
|
This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
|
|
227
|
-
in `readOrder` (branch → repo → global) and keeps the first value seen
|
|
228
|
-
a more-specific scope overrides a broader scope's same-key lesson. It
|
|
229
|
-
"which lesson actually applies here, and what is being overridden?".
|
|
230
|
-
`project::` scope is
|
|
231
|
-
|
|
232
|
-
Each store is resolved independently, in the same Offline / Remote split.
|
|
229
|
+
in `readOrder` (project → branch → repo → global) and keeps the first value seen
|
|
230
|
+
per key, so a more-specific scope overrides a broader scope's same-key lesson. It
|
|
231
|
+
answers "which lesson actually applies here, and what is being overridden?". The
|
|
232
|
+
`project::` scope **is** part of the injected set (project is the most-specific
|
|
233
|
+
scope), so `tree`, the hooks, and every read command's `scopeList` now share one
|
|
234
|
+
ordering. Each store is resolved independently, in the same Offline / Remote split.
|
|
233
235
|
|
|
234
236
|
### `lorekit lint`
|
|
235
237
|
|
|
@@ -273,8 +275,9 @@ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store
|
|
|
273
275
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
274
276
|
It is not run by hand — the plugins wire it into their hook config. It reads
|
|
275
277
|
the host framework's JSON on stdin and prints that host's injection format on
|
|
276
|
-
stdout (lessons at session start; a nudge on
|
|
277
|
-
always exiting 0 so it can never
|
|
278
|
+
stdout (lessons at session start; relevant lessons plus a write-nudge on a tool
|
|
279
|
+
failure; a retrospective nudge at end of turn), always exiting 0 so it can never
|
|
280
|
+
block the host agent.
|
|
278
281
|
|
|
279
282
|
```bash
|
|
280
283
|
lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
|
package/package.json
CHANGED
package/src/core/lessons.mjs
CHANGED
|
@@ -3,23 +3,52 @@
|
|
|
3
3
|
// Storage is reached through the resolved store (local | remote), never a
|
|
4
4
|
// backend directly, so the same read path serves every mode.
|
|
5
5
|
import { deriveScope } from '../scope.mjs';
|
|
6
|
+
// The precedence merge and the literal substring matcher come from the
|
|
7
|
+
// dependency-free `lessons-pure.mjs` — the SAME primitives `tree` and `search`
|
|
8
|
+
// use, so the hook can't drift from them, and the hot path never pulls in the
|
|
9
|
+
// `lessons-view.mjs` render/`util` stack.
|
|
10
|
+
import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
|
|
6
11
|
|
|
7
12
|
const MAX_LESSONS = 15;
|
|
13
|
+
// Cap on lessons injected on a failure — a small, focused "you've seen this
|
|
14
|
+
// before" set, never the whole applicable corpus.
|
|
15
|
+
const MAX_RELEVANT = 3;
|
|
16
|
+
// Cap on the terms distilled from a failure — bounds the relevance scan so a
|
|
17
|
+
// huge error blob can never turn into an unbounded match loop.
|
|
18
|
+
const MAX_TERMS = 12;
|
|
19
|
+
// Terms shorter than this are dropped: too generic to make a match meaningful.
|
|
20
|
+
const MIN_TERM_LEN = 4;
|
|
21
|
+
// Cap on how much failure text is scanned for terms. A tool can dump a
|
|
22
|
+
// multi-megabyte stderr/stdout; the salient error words are always near the
|
|
23
|
+
// front, so we bound the input BEFORE lowercasing/splitting it — otherwise a
|
|
24
|
+
// giant blob would materialise a giant token array (a CPU/memory spike) even
|
|
25
|
+
// though the term COUNT is capped. Generous enough to never clip a real error.
|
|
26
|
+
const MAX_SCAN_CHARS = 4096;
|
|
8
27
|
|
|
9
|
-
// Read lessons narrow-to-broad through the store and
|
|
10
|
-
//
|
|
28
|
+
// Read lessons narrow-to-broad through the store and resolve cross-scope
|
|
29
|
+
// precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
|
|
30
|
+
// more-specific-wins merge `tree` renders) — so the hook and `tree` provably
|
|
31
|
+
// can't drift. Any per-scope failure is skipped (memory is best-effort).
|
|
11
32
|
export async function fetchLessons(store, cwd) {
|
|
12
33
|
const scope = deriveScope(cwd);
|
|
13
|
-
const
|
|
34
|
+
const groups = [];
|
|
14
35
|
for (const s of scope.readOrder) {
|
|
15
36
|
const res = await store.list({ scope: s, limit: 25 });
|
|
16
|
-
if (!res || !res.ok) continue;
|
|
17
|
-
const entries = Array.isArray(res.entries) ? res.entries : []
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
37
|
+
if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
|
|
38
|
+
const entries = (Array.isArray(res.entries) ? res.entries : [])
|
|
39
|
+
.filter((e) => e && e.key)
|
|
40
|
+
.map((e) => ({ ...e, scope: s }));
|
|
41
|
+
groups.push({ scope: s, error: null, entries });
|
|
21
42
|
}
|
|
22
|
-
|
|
43
|
+
// First value per key wins (most-specific scope, since `readOrder` is
|
|
44
|
+
// narrow→broad) — exactly what the old inline `byKey` merge did, now via the
|
|
45
|
+
// one shared resolver. The winners, in group order, are the injected set.
|
|
46
|
+
const { groups: resolved } = resolvePrecedence({ groups });
|
|
47
|
+
const lessons = [];
|
|
48
|
+
for (const g of resolved) {
|
|
49
|
+
for (const e of g.entries) if (e.winning) lessons.push(e);
|
|
50
|
+
}
|
|
51
|
+
return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
|
|
23
52
|
}
|
|
24
53
|
|
|
25
54
|
// Render lessons as a compact markdown block, or null when there are none.
|
|
@@ -37,6 +66,60 @@ export function formatLessons(lessons, scope) {
|
|
|
37
66
|
return `${header}\n${body}`;
|
|
38
67
|
}
|
|
39
68
|
|
|
69
|
+
// Distil a small set of significant, lowercased search TERMS from a tool
|
|
70
|
+
// failure — the tool name plus the salient words of its error text — used to
|
|
71
|
+
// look up lessons that might already cover this failure. Pure and total: any
|
|
72
|
+
// shape of `toolResponse` (object, string, null, malformed) is handled without
|
|
73
|
+
// throwing. Terms are de-duplicated, stopword- and length-filtered so a match
|
|
74
|
+
// stays meaningful, and the count is capped (`MAX_TERMS`) so a huge error blob
|
|
75
|
+
// can't blow up the downstream scan.
|
|
76
|
+
export function failureQuery(toolName, toolResponse) {
|
|
77
|
+
const text = `${toolName ? String(toolName) : ''} ${errorText(toolResponse)}`.slice(0, MAX_SCAN_CHARS);
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const terms = [];
|
|
80
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
81
|
+
if (raw.length < MIN_TERM_LEN || STOPWORDS.has(raw) || seen.has(raw)) continue;
|
|
82
|
+
seen.add(raw);
|
|
83
|
+
terms.push(raw);
|
|
84
|
+
if (terms.length >= MAX_TERMS) break;
|
|
85
|
+
}
|
|
86
|
+
return terms;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lessons whose key OR value literally contains ANY of the failure `terms`
|
|
90
|
+
// (case-insensitive, via the shared `matchesQuery` — never a regex), capped at
|
|
91
|
+
// `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
|
|
92
|
+
// falls back to the write-nudge alone). Preserves `lessons` order, so the
|
|
93
|
+
// most-specific scope's relevant lesson surfaces first.
|
|
94
|
+
export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
|
|
95
|
+
if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const l of lessons) {
|
|
100
|
+
if (terms.some((t) => matchesQuery(l, t))) out.push(l);
|
|
101
|
+
if (out.length >= cap) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Render the relevant-lessons block injected alongside the failure nudge, or
|
|
107
|
+
// null when nothing matched. Framed as prior art, not a directive — same
|
|
108
|
+
// "considerations, not rules" stance as `formatLessons`.
|
|
109
|
+
export function formatRelevantLessons(lessons) {
|
|
110
|
+
if (!lessons || lessons.length === 0) return null;
|
|
111
|
+
const header =
|
|
112
|
+
`LoreKit — you've hit something like this before. ${lessons.length} related ` +
|
|
113
|
+
'lesson(s) (considerations, not rules; trust the current code if they conflict):';
|
|
114
|
+
const body = lessons
|
|
115
|
+
.map((l) => {
|
|
116
|
+
const first = String(l.value || '').split('\n')[0].slice(0, 300);
|
|
117
|
+
return `- (${l.scope}) ${l.key}: ${first}`;
|
|
118
|
+
})
|
|
119
|
+
.join('\n');
|
|
120
|
+
return `${header}\n${body}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
40
123
|
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
41
124
|
export function retrospectiveNudge(scope) {
|
|
42
125
|
const writeScope = scope.repoScope || 'global';
|
|
@@ -58,3 +141,64 @@ export function failureNudge(toolName, scope) {
|
|
|
58
141
|
`lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
|
|
59
142
|
);
|
|
60
143
|
}
|
|
144
|
+
|
|
145
|
+
// Pull the human-readable error text out of a tool_response of any shape. Total:
|
|
146
|
+
// null/primitive/string/object all yield a (possibly empty) string, never a
|
|
147
|
+
// throw — the relevance lookup is best-effort and must not break the host.
|
|
148
|
+
function errorText(response) {
|
|
149
|
+
if (response == null) return '';
|
|
150
|
+
if (typeof response === 'string') return response;
|
|
151
|
+
if (typeof response !== 'object') return String(response);
|
|
152
|
+
const parts = [];
|
|
153
|
+
for (const f of TEXT_FIELDS) {
|
|
154
|
+
const v = response[f];
|
|
155
|
+
if (typeof v === 'string') parts.push(v);
|
|
156
|
+
else if (v && typeof v === 'object') parts.push(safeStringify(v));
|
|
157
|
+
}
|
|
158
|
+
return parts.length ? parts.join(' ') : safeStringify(response);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeStringify(v) {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(v) || '';
|
|
164
|
+
} catch {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The tool_response fields that commonly carry error text, across frameworks.
|
|
170
|
+
const TEXT_FIELDS = [
|
|
171
|
+
'stderr',
|
|
172
|
+
'error',
|
|
173
|
+
'message',
|
|
174
|
+
'stdout',
|
|
175
|
+
'output',
|
|
176
|
+
'content',
|
|
177
|
+
'reason',
|
|
178
|
+
'detail',
|
|
179
|
+
'details',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Generic words dropped from a failure query: too common to make a lesson match
|
|
183
|
+
// meaningful (they'd match almost anything). Kept small and hand-picked.
|
|
184
|
+
const STOPWORDS = new Set([
|
|
185
|
+
'error',
|
|
186
|
+
'errors',
|
|
187
|
+
'failed',
|
|
188
|
+
'failure',
|
|
189
|
+
'tool',
|
|
190
|
+
'call',
|
|
191
|
+
'code',
|
|
192
|
+
'exit',
|
|
193
|
+
'with',
|
|
194
|
+
'this',
|
|
195
|
+
'that',
|
|
196
|
+
'from',
|
|
197
|
+
'null',
|
|
198
|
+
'true',
|
|
199
|
+
'false',
|
|
200
|
+
'undefined',
|
|
201
|
+
'command',
|
|
202
|
+
'response',
|
|
203
|
+
'status',
|
|
204
|
+
]);
|
package/src/hook.mjs
CHANGED
|
@@ -7,7 +7,15 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
7
7
|
import { deriveScope } from './scope.mjs';
|
|
8
8
|
import { loadControl } from './control.mjs';
|
|
9
9
|
import { createStore } from './store/index.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
fetchLessons,
|
|
12
|
+
formatLessons,
|
|
13
|
+
retrospectiveNudge,
|
|
14
|
+
failureNudge,
|
|
15
|
+
failureQuery,
|
|
16
|
+
relevantLessons,
|
|
17
|
+
formatRelevantLessons,
|
|
18
|
+
} from './core/lessons.mjs';
|
|
11
19
|
import { isFailure } from './core/failure.mjs';
|
|
12
20
|
import { firstTimeThisSession } from './core/state.mjs';
|
|
13
21
|
import { recordFixture } from './core/record.mjs';
|
|
@@ -92,7 +100,22 @@ async function run(args) {
|
|
|
92
100
|
const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
|
|
93
101
|
if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
|
|
94
102
|
if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
|
|
95
|
-
|
|
103
|
+
// Best-effort: surface any existing lessons that look relevant to THIS
|
|
104
|
+
// failure ("you've hit this before"), then the write-nudge. A missing /
|
|
105
|
+
// unusable store, or no match, silently falls back to the nudge alone.
|
|
106
|
+
let relevant = null;
|
|
107
|
+
try {
|
|
108
|
+
const store = createStore(control);
|
|
109
|
+
if (store) {
|
|
110
|
+
const { lessons } = await fetchLessons(store, root);
|
|
111
|
+
const terms = failureQuery(parsed.toolName, parsed.toolResponse);
|
|
112
|
+
relevant = formatRelevantLessons(relevantLessons(lessons, terms));
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
relevant = null; // never let a lesson lookup break the failure nudge
|
|
116
|
+
}
|
|
117
|
+
const nudge = failureNudge(parsed.toolName, scope);
|
|
118
|
+
emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
|
|
96
119
|
return 0;
|
|
97
120
|
}
|
|
98
121
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure, DEPENDENCY-FREE lesson primitives shared by the hook engine
|
|
2
|
+
// (`core/lessons.mjs`, the hot path) and the read-command view layer
|
|
3
|
+
// (`lessons-view.mjs`, which re-exports these). Kept in its own module so the
|
|
4
|
+
// hook can import the exact precedence + match logic the read commands use —
|
|
5
|
+
// one source of truth, no drift — WITHOUT dragging in the rendering/`util`
|
|
6
|
+
// stack (`heading`/`log`/`c`, plus the lint/dedupe/diff cores) that the rest of
|
|
7
|
+
// `lessons-view.mjs` carries. Zero imports on purpose.
|
|
8
|
+
|
|
9
|
+
// ── cross-scope precedence resolution (the `tree` + hook merge core) ──────────
|
|
10
|
+
|
|
11
|
+
// Given per-scope groups in RESOLUTION ORDER — most-specific first, exactly the
|
|
12
|
+
// order `deriveScope().readOrder` produces — compute which scope's lesson WINS
|
|
13
|
+
// each key and which are shadowed. This is the hook engine's merge: it iterates
|
|
14
|
+
// the scopes narrow-to-broad and keeps the FIRST value seen per key (`if
|
|
15
|
+
// (!winnerScopeByKey.has(key)) set`), so a more-specific scope shadows a broader
|
|
16
|
+
// scope's same-key lesson. Returns the same groups with every entry tagged
|
|
17
|
+
// `{ winning, shadowedBy }` (`shadowedBy` = the scope that won the key, or null
|
|
18
|
+
// for a winner), plus the resolved `winners` list (`{ scope, key }`, one per
|
|
19
|
+
// key) and winning/shadowed counts. A per-scope read error is passed through
|
|
20
|
+
// untouched (empty entries), so one unreadable scope never derails resolution.
|
|
21
|
+
// Pure — consumed by both `tree` (display) and `fetchLessons` (injection).
|
|
22
|
+
export function resolvePrecedence({ groups = [] } = {}) {
|
|
23
|
+
const winnerScopeByKey = new Map(); // key → the scope that first claimed it
|
|
24
|
+
const outGroups = [];
|
|
25
|
+
let winningTotal = 0;
|
|
26
|
+
let shadowedTotal = 0;
|
|
27
|
+
for (const g of groups) {
|
|
28
|
+
if (g.error) {
|
|
29
|
+
outGroups.push({ scope: g.scope, error: g.error, entries: [] });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const e of g.entries || []) {
|
|
34
|
+
const prior = winnerScopeByKey.get(e.key);
|
|
35
|
+
if (prior === undefined) {
|
|
36
|
+
winnerScopeByKey.set(e.key, g.scope);
|
|
37
|
+
entries.push({ ...e, winning: true, shadowedBy: null });
|
|
38
|
+
winningTotal += 1;
|
|
39
|
+
} else {
|
|
40
|
+
entries.push({ ...e, winning: false, shadowedBy: prior });
|
|
41
|
+
shadowedTotal += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
outGroups.push({ scope: g.scope, error: null, entries });
|
|
45
|
+
}
|
|
46
|
+
const winners = [...winnerScopeByKey.entries()].map(([key, scope]) => ({ scope, key }));
|
|
47
|
+
return { groups: outGroups, winners, winningTotal, shadowedTotal };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── literal, case-insensitive substring matching (the `search` + hook core) ───
|
|
51
|
+
|
|
52
|
+
// Case-insensitive, LITERAL substring match of `query` against a normalized
|
|
53
|
+
// entry's key OR value — the `search` command's matcher, and the hook's
|
|
54
|
+
// failure-relevance matcher. Deliberately a plain `String.includes` (never
|
|
55
|
+
// `new RegExp(query)`) so a query full of regex metacharacters like `a.*(b)`
|
|
56
|
+
// matches those characters verbatim, never as a pattern. An empty query matches
|
|
57
|
+
// everything (the command guards emptiness as a usage error before ever calling
|
|
58
|
+
// this). Pure — trivially unit-testable.
|
|
59
|
+
export function matchesQuery(entry, query) {
|
|
60
|
+
const needle = String(query == null ? '' : query).toLowerCase();
|
|
61
|
+
if (!needle) return true;
|
|
62
|
+
const key = String(entry?.key ?? '').toLowerCase();
|
|
63
|
+
const value = String(entry?.value ?? '').toLowerCase();
|
|
64
|
+
return key.includes(needle) || value.includes(needle);
|
|
65
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
Binary file
|
package/src/scope.mjs
CHANGED
|
@@ -44,7 +44,11 @@ export function deriveScope(cwd = process.cwd()) {
|
|
|
44
44
|
: null;
|
|
45
45
|
const projectScope = `project::${projectName}`;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Injection / resolution order, most-specific → broadest. Matches the read
|
|
48
|
+
// commands' `scopeList` exactly (`lessons-view.mjs`) — project is FIRST, so a
|
|
49
|
+
// project-scoped lesson wins and IS injected. De-duplicated (a project whose
|
|
50
|
+
// basename collides with a scope, or a repo with no branch, never repeats).
|
|
51
|
+
const readOrder = [...new Set([projectScope, branchScope, repoScope, 'global'].filter(Boolean))];
|
|
48
52
|
|
|
49
53
|
return {
|
|
50
54
|
ownerRepo,
|
package/src/tree.mjs
CHANGED
|
@@ -5,17 +5,18 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Precedence is not an assumption — it mirrors the hook engine exactly. The
|
|
7
7
|
// SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
|
|
8
|
-
// `deriveScope().readOrder` (branch → repo → global, most-specific
|
|
9
|
-
// keeps the FIRST value seen per key, so a more-specific scope
|
|
10
|
-
// scope's same-key lesson. `tree` resolves over that same
|
|
11
|
-
// the pure `resolvePrecedence`, so it shows the same
|
|
12
|
-
// is injected with (the hook additionally caps the
|
|
13
|
-
// `tree` is uncapped, so a large workspace may list
|
|
14
|
-
// injects).
|
|
8
|
+
// `deriveScope().readOrder` (project → branch → repo → global, most-specific
|
|
9
|
+
// first) and keeps the FIRST value seen per key, so a more-specific scope
|
|
10
|
+
// shadows a broader scope's same-key lesson. `tree` resolves over that same
|
|
11
|
+
// `readOrder` set via the pure `resolvePrecedence`, so it shows the same
|
|
12
|
+
// resolution order the agent is injected with (the hook additionally caps the
|
|
13
|
+
// injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
|
|
14
|
+
// more winners than the hook injects).
|
|
15
15
|
//
|
|
16
|
-
// NOTE on scope coverage: `readOrder` is the injected set
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
|
|
17
|
+
// PR it INCLUDES `project::` (project is the most-specific scope and now wins /
|
|
18
|
+
// is injected), so `tree` shows it too — the ordering is now unified across the
|
|
19
|
+
// hook, `tree`, and every read command's `scopeList`. Both stores are resolved
|
|
19
20
|
// independently (precedence is per-store — the hook reads one resolved store),
|
|
20
21
|
// in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
|
|
21
22
|
// `traceCommand` by the bin.
|
|
@@ -24,7 +25,8 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
24
25
|
import { deriveScope } from './scope.mjs';
|
|
25
26
|
import { resolveDenies } from './control.mjs';
|
|
26
27
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
27
|
-
import {
|
|
28
|
+
import { resolvePrecedence } from './lessons-pure.mjs';
|
|
29
|
+
import { gather, preview, shortDate } from './lessons-view.mjs';
|
|
28
30
|
import { log, heading, status, c } from './util.mjs';
|
|
29
31
|
|
|
30
32
|
export async function tree(args) {
|