@lorekit/cli 1.69.0 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ # Recipe card — multi-step orchestrator
2
+
3
+ For a **pipeline that fails in classifiable ways** but does not post durable outputs at
4
+ a shared target and does not (necessarily) edit code: a triage → plan → execute
5
+ workflow, a release orchestrator, a batch processor. The plain two-tier lessons loop
6
+ fits it exactly. This is the card to start from when no other card matches.
7
+
8
+ Fill in: `<host>` (e.g. `deploy`), `<owner>/<repo>`. `N = 5`.
9
+
10
+ ## Bucket
11
+
12
+ Tag `loop::<host>-lessons`, key `<host>-lessons::<slug>`. One bucket per host.
13
+
14
+ ## Read step — start of every run
15
+
16
+ ```text
17
+ memory.list { scope: "repo::<owner>/<repo>", tags: ["loop::<host>-lessons"], limit: 5 }
18
+ memory.list { scope: "global", tags: ["loop::<host>-lessons"], limit: 5 }
19
+ # when the run names a stage/error, one targeted search:
20
+ memory.search { q: "<keywords>", scopes: ["repo::<owner>/*", "global"], limit: 5 }
21
+ ```
22
+
23
+ Match each **Applies when** against the current run; apply matching **Do this instead**
24
+ lines as considerations. `repo::` beats `global` on a collision.
25
+
26
+ ## Write step — at the host's EXISTING failure points
27
+
28
+ Do not add a new reflection stage — hook the points the host already detects (a stuck
29
+ loop, a repeated failure, a gate that should have caught something, a near-miss, a
30
+ guess that paid off). Not on smooth successes.
31
+
32
+ ```text
33
+ memory.search { q: "<key words of the lesson>", scopes: ["repo::<owner>/<repo>", "global"], limit: 10 }
34
+
35
+ memory.write {
36
+ scope: "<global | repo::<owner>/<repo>>",
37
+ key: "<host>-lessons::<slug>",
38
+ value: "<markdown lesson body — no hidden blocks>",
39
+ tags: ["loop::<host>-lessons", "source::<trigger>"], # + "status::structural" when it is
40
+ trigger: "<stuck-loop | command-failure | gotcha | near-miss | assumption-wrong | paid-off>",
41
+ ttl_days: 90
42
+ }
43
+ ```
44
+
45
+ Same `scope` + `key` UPDATEs in place — the store increments `seen_count` for you and
46
+ re-passing `ttl_days` refreshes the expiry. Never hand-write a count into the body.
47
+
48
+ ## Lesson body — copy this shape
49
+
50
+ ```markdown
51
+ # <one-line takeaway — what to do, not what it is about>
52
+
53
+ **Applies when:** <concrete signal — stage name, task type, tool name, error shape>
54
+
55
+ **What happened:** <the concrete observable>
56
+ **Why:** <root cause, or "unknown">
57
+ **Do this instead:** <prescriptive, testable instruction>
58
+ **Promotion target:** <the host step this would harden if promoted, or "none">
59
+ ```
60
+
61
+ ## Promotion
62
+
63
+ When a lesson hits `seen_count >= 3` (read the column, not the body) or carries
64
+ `status::structural`, surface a one-line promotion suggestion — never act silently.
65
+ See [Promotion](../rules/self-improvement-loops.md#promotion-fast--slow).
66
+
67
+ ## Fire-once check
68
+
69
+ 1. Force the failure this loop targets.
70
+ 2. `memory.list { scope: "<expected>", tags: ["loop::<host>-lessons"], limit: 10 }` — confirm the lesson.
71
+ 3. Re-run; confirm it surfaces and biases the run.
72
+
73
+ ## Cold start & proof
74
+
75
+ Optional capped seeding: [cold-start-seeding.md](../rules/cold-start-seeding.md).
76
+ Required proof — the immunity re-challenge: [proving-improvement.md](../rules/proving-improvement.md).
@@ -0,0 +1,86 @@
1
+ # Recipe card — reviewer / reconcile host
2
+
3
+ For a host that **produces durable outputs at a shared target it revisits**: a PR
4
+ reviewer posting comment threads it re-reviews on every push, a triager filing issues
5
+ it re-scans, a linter opening tickets. A plain read/write loop is not enough — stale
6
+ outputs pile up at the target and the signal about which outputs were *useful* is
7
+ thrown away. This card adds the **reconcile-on-re-run** flow on top of the lessons
8
+ loop, feeding a second **Signal** bucket.
9
+
10
+ Fill in: `<host>` (e.g. `reviewer`), `<signal>` (e.g. `comment-relevance`),
11
+ `<owner>/<repo>`. `N = 5`.
12
+
13
+ ## Buckets (two)
14
+
15
+ - Lessons: tag `loop::<host>-lessons`, key `<host>-lessons::<slug>` — how to review better.
16
+ - Signal: tag `loop::<host>-<signal>`, key `<host>-<signal>::<pattern-fingerprint>` —
17
+ which of the host's OUTPUT PATTERNS get acted on vs declined at this target.
18
+
19
+ ## Read step — start of run
20
+
21
+ ```text
22
+ # Own lessons (capped at N per scope):
23
+ memory.list { scope: "repo::<owner>/<repo>", tags: ["loop::<host>-lessons"], limit: 5 }
24
+ memory.list { scope: "global", tags: ["loop::<host>-lessons"], limit: 5 }
25
+
26
+ # Signal bucket — suppress reliably-declined patterns, reinforce reliably-resolved ones:
27
+ memory.list { scope: "repo::<owner>/<repo>", tags: ["loop::<host>-<signal>"], limit: 20 }
28
+ ```
29
+
30
+ ## Reconcile step — at the re-run seam, gated on "a prior output exists at this target"
31
+
32
+ For each prior output the host itself authored, classify against the current target:
33
+
34
+ | Outcome | Meaning | Evidence |
35
+ | --- | --- | --- |
36
+ | **resolved** | acted on — the flagged thing is handled | the region changed and the finding no longer reproduces, or the owner acknowledged it |
37
+ | **declined** | explicitly rejected | a "won't fix" / "by design" reply, a 👎 |
38
+ | **still-open** | still reproduces this run | the host re-produces the same output |
39
+
40
+ Then:
41
+
42
+ 1. **Clean up** `resolved` + `declined` at the source (resolve the thread, close the
43
+ ticket). **Never** touch a `still-open` output. Only ever touch outputs the host
44
+ authored. Cleanup is idempotent and non-fatal (a cleanup error is logged, never
45
+ fails the run).
46
+ 2. **Record the outcome** to the Signal bucket, keyed by a stable pattern fingerprint
47
+ (never a line number or a drifting id):
48
+
49
+ ```text
50
+ memory.write {
51
+ scope: "repo::<owner>/<repo>",
52
+ key: "<host>-<signal>::<pattern-fingerprint>",
53
+ value: "<markdown: the pattern, and resolved-vs-declined evidence — no hidden blocks>",
54
+ tags: ["loop::<host>-<signal>", "status::<resolved | declined>"],
55
+ trigger: "reconcile",
56
+ ttl_days: 90
57
+ }
58
+ ```
59
+
60
+ `still-open` writes nothing — there is no outcome yet. **Absence of confirmation is not
61
+ resolution**: if a re-run did not re-scan the region a prior output covers, it is
62
+ `still-open`, not `resolved`.
63
+
64
+ ## Write step (lessons) — on friction, as usual
65
+
66
+ Same as any lessons loop — see [code-changing-agent.md](./code-changing-agent.md#write-step--on-failure--at-end-of-run)
67
+ for the write shape.
68
+
69
+ ## Fire-once check
70
+
71
+ 1. Produce an output at a test target, then resolve it at the source by hand.
72
+ 2. Re-run; confirm the host classifies it `resolved`, cleans it up, and writes a
73
+ positive Signal record for its pattern.
74
+ 3. Start a third run; confirm the Signal record surfaces and reinforces that pattern.
75
+
76
+ ## Reference implementation
77
+
78
+ The `agent-skills` `pr-reviewer` agent: it resolves its own addressed PR threads on
79
+ each commit-triggered re-review and records the fixed/declined outcome to a
80
+ `reviewer-comment-relevance` bucket. Full flow:
81
+ [the reconcile-on-re-run flow](../rules/self-improvement-loops.md#the-reconcile-on-re-run-flow-resolve--record).
82
+
83
+ ## Prove it
84
+
85
+ [proving-improvement.md](../rules/proving-improvement.md) — for this host the signal is
86
+ the **decline rate** of the host's outputs trending down over runs.
@@ -7,7 +7,6 @@ import { execFileSync } from 'node:child_process';
7
7
  import {
8
8
  SKILLS,
9
9
  resolveProjectRoot,
10
- skillInstallDir,
11
10
  CLAUDE_HOOK_EVENTS,
12
11
  installedHookEvents,
13
12
  hookModeFromEvents,
@@ -17,6 +16,7 @@ import {
17
16
  tokenKind,
18
17
  readLorekitJson,
19
18
  } from '../shared/config.mjs';
19
+ import { checkSkillVersions } from '../shared/skill-versions.mjs';
20
20
  import { splitEndpoint } from '../shared/mcp.mjs';
21
21
  import {
22
22
  resolveTelemetryConfig,
@@ -71,18 +71,48 @@ export async function doctor(args) {
71
71
  // skill under home, not the repo, so a project-only check reports a healthy
72
72
  // global install as "not found" (exactly the false FAIL a --global setup would
73
73
  // hit).
74
+ //
75
+ // Beyond existence, each installed scope now reports its VERSION against the
76
+ // one this CLI ships (`checkSkillVersions` — an offline compare of
77
+ // `metadata.version` in SKILL.md's frontmatter, see `shared/skill-versions.mjs`).
78
+ // An `outdated` or `unknown` (legacy, unparseable) install is a `warn`, never a
79
+ // `fail` — the skill still works, it just has no signal to update without this
80
+ // line — and names the fix scope-aware, mirroring the hooks-upgrade wording
81
+ // above: `lorekit update` is what a stale install is missing, the same way
82
+ // `lorekit install --hooks <mode>` is what a stale hook wiring is missing.
83
+ const skillVersions = checkSkillVersions(root);
74
84
  for (const skill of SKILLS) {
75
- const skillMd = [
76
- skillInstallDir(root, 'project', skill.name),
77
- skillInstallDir(root, 'global', skill.name),
78
- ]
79
- .map((dir) => path.join(dir, 'SKILL.md'))
80
- .find((p) => fs.existsSync(p));
81
- if (skillMd) {
82
- const rel = path.relative(root, skillMd);
83
- record('pass', `skill ${skill.name}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
84
- } else {
85
+ const installedEntries = skillVersions.filter(
86
+ (r) => r.name === skill.name && r.state !== 'not-installed',
87
+ );
88
+ if (installedEntries.length === 0) {
85
89
  record('fail', `skill ${skill.name}`, 'not found — run `lorekit install`');
90
+ continue;
91
+ }
92
+ for (const entry of installedEntries) {
93
+ const rel = path.relative(root, entry.installedPath);
94
+ const displayPath = rel && !rel.startsWith('..') ? rel : prettyPath(entry.installedPath);
95
+ // Only distinguish the scope in the label when the skill is installed in
96
+ // more than one — the common single-scope case keeps the pre-existing
97
+ // `skill <name>` label so it doesn't churn every doctor transcript.
98
+ const label = installedEntries.length > 1 ? `skill ${skill.name} (${entry.scope})` : `skill ${skill.name}`;
99
+ if (entry.state === 'current') {
100
+ record('pass', label, `${displayPath} — v${entry.installed}`);
101
+ } else if (entry.state === 'outdated') {
102
+ record(
103
+ 'warn',
104
+ label,
105
+ `${displayPath} — v${entry.installed}, shipped v${entry.shipped} — ` +
106
+ `run \`lorekit update --${entry.scope}\` to refresh`,
107
+ );
108
+ } else {
109
+ record(
110
+ 'warn',
111
+ label,
112
+ `${displayPath} — version unknown (legacy install predates version stamping) — ` +
113
+ `run \`lorekit update --${entry.scope}\` to refresh`,
114
+ );
115
+ }
86
116
  }
87
117
  }
88
118
 
@@ -30,6 +30,7 @@ import {
30
30
  recordShownLessons,
31
31
  } from '../core/state.mjs';
32
32
  import { recordFixture } from '../core/record.mjs';
33
+ import { resolveUpdateNudge } from '../core/update-notify.mjs';
33
34
  import { claude } from '../adapters/claude.mjs';
34
35
  import { cursor } from '../adapters/cursor.mjs';
35
36
  import { codex } from '../adapters/codex.mjs';
@@ -62,6 +63,20 @@ function hookMeterAttrs(meter) {
62
63
  return attrs;
63
64
  }
64
65
 
66
+ // Append the offline skill-update nudge to the SessionStart block, when the
67
+ // throttle allows it. Best-effort by design — a broken drift check or a
68
+ // throttle-file write failure must never cost the reader their lessons block,
69
+ // so any error here silently falls back to `text` unmodified.
70
+ function appendUpdateNudge(text, root, control) {
71
+ try {
72
+ const nudge = resolveUpdateNudge(root, { notify: control.updatesNotify });
73
+ if (!nudge) return text;
74
+ return text ? `${text}\n\n${nudge}` : nudge;
75
+ } catch {
76
+ return text;
77
+ }
78
+ }
79
+
65
80
  function readStdin() {
66
81
  return new Promise((resolve) => {
67
82
  let data = '';
@@ -164,9 +179,10 @@ async function run(args, meter) {
164
179
  // No store: emit a minimal header + instruction when present, then return.
165
180
  // A SessionStart that cannot read lore is the highest-value failure to see.
166
181
  meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
167
- if (sessionInstruction) {
168
- emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
169
- }
182
+ const base = sessionInstruction
183
+ ? formatLessons(null, { repoScope: null }, { instruction: sessionInstruction })
184
+ : null;
185
+ emit(appendUpdateNudge(base, root, control));
170
186
  return 0;
171
187
  }
172
188
  const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
@@ -174,7 +190,7 @@ async function run(args, meter) {
174
190
  branchHint: control.hooksSessionStartBranchHint !== 'off',
175
191
  maxLessons: control.hooksSessionStartMaxLessons,
176
192
  });
177
- emit(formatLessons(lessons, readScope, {
193
+ const lessonsBlock = formatLessons(lessons, readScope, {
178
194
  instruction: sessionInstruction,
179
195
  mode: control.hooksSessionStart,
180
196
  maxChars: control.hooksSessionStartMaxChars,
@@ -192,7 +208,8 @@ async function run(args, meter) {
192
208
  // `recordShownLessons` never throws, and a failure costs at most one
193
209
  // repeated lesson later in the session.
194
210
  onShown: (rendered) => recordShownLessons(parsed.sessionId, rendered.map(lessonId)),
195
- }));
211
+ });
212
+ emit(appendUpdateNudge(lessonsBlock, root, control));
196
213
  return 0;
197
214
  }
198
215
 
@@ -1,8 +1,10 @@
1
1
  // `lorekit lint` — flag low-quality lessons across the applicable scopes and
2
2
  // both stores. Each finding names the rule it violated (empty/whitespace value,
3
3
  // suspiciously short value, untrimmed value, empty key, volatile key, malformed
4
- // scope). The rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` /
5
- // `lintEntry`), each independently unit-tested.
4
+ // scope, unkinded state record, hidden metadata — an HTML comment / front-matter /
5
+ // `key=value` header in a body that should be pure markdown). The rules are pure
6
+ // predicates in `lessons-view.mjs` (`LINT_RULES` / `lintEntry`), each
7
+ // independently unit-tested.
6
8
  //
7
9
  // Exit convention: `lint` exits NON-ZERO (1) when any finding exists, so it is
8
10
  // usable as a CI gate (`lorekit lint || fail`); a clean run — or a run where the
@@ -0,0 +1,190 @@
1
+ // `lorekit update` — refresh the bundled skills (and their hook wiring) to the
2
+ // versions shipped with the running CLI.
3
+ //
4
+ // Fully offline: the shipped skill source travels in the SAME npm tarball as
5
+ // this running CLI (`SKILLS[].source` in `shared/config.mjs`), so "installed
6
+ // vs shipped" is a filesystem compare with zero network calls — see
7
+ // `shared/skill-versions.mjs`, the same module `doctor` and the SessionStart
8
+ // drift nudge (`core/update-notify.mjs`) read through, so all three surfaces
9
+ // agree on what counts as outdated.
10
+ //
11
+ // `--check` is a dry run: report drift, write nothing. Without it, `update`
12
+ // first PRUNES any file present in the install that the shipped skill no
13
+ // longer ships (`pruneRemoved` — `copyDir` only ever writes, so without this
14
+ // a rule file a newer version dropped would survive every future refresh
15
+ // forever), then re-copies (force) every skill into whichever scope(s)
16
+ // already have an install — reusing `copyDir`, the exact same skill-copy path
17
+ // `install` uses, so the two can never drift on what "installing a skill"
18
+ // means — and refreshes that scope's hook command string via `upsertClaudeHooks`, the
19
+ // same call `install --force` makes. That refresh is deliberately in scope:
20
+ // a stale `npx -y @lorekit/cli@1.2.3 hook …` pin or an old runner path is the
21
+ // same class of drift this command exists to fix, the call is idempotent
22
+ // (it only rewrites the command string, never the wired event set), and
23
+ // skipping it would leave `update` unable to repair the one other thing an
24
+ // install can go stale on. `--project` / `--global` narrow to one scope;
25
+ // with neither, every scope that currently has an existing skill install is
26
+ // refreshed — `update` never CREATES a fresh install, that stays `install`'s
27
+ // job.
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import {
31
+ SKILLS,
32
+ resolveProjectRoot,
33
+ skillInstallDir,
34
+ copyDir,
35
+ installedHookEvents,
36
+ upsertClaudeHooks,
37
+ resolveHookRunner,
38
+ } from '../shared/config.mjs';
39
+ import { checkSkillVersions, SKILL_SCOPES } from '../shared/skill-versions.mjs';
40
+ import { log, heading, status, c } from '../shared/util.mjs';
41
+
42
+ // Which scopes `update` should touch. Explicit `--project`/`--global` narrow
43
+ // to exactly one — but only when that scope actually has something installed;
44
+ // naming a scope with nothing in it must still fall through to the "nothing
45
+ // to update" report below, never silently report health on an empty scope.
46
+ // `--global` is checked first, matching `install`/`uninstall`'s own
47
+ // scope-flag precedence, so `--project --global` never picks the opposite
48
+ // scope from its sibling commands.
49
+ // With neither flag, every scope holding at least one installed skill (any
50
+ // state other than `not-installed`) is refreshed.
51
+ function targetScopes(args, results) {
52
+ const hasInstall = (scope) => results.some((r) => r.scope === scope && r.state !== 'not-installed');
53
+ if (args.global) return hasInstall('global') ? ['global'] : [];
54
+ if (args.project) return hasInstall('project') ? ['project'] : [];
55
+ return SKILL_SCOPES.filter(hasInstall);
56
+ }
57
+
58
+ // Remove any file under `dest` that no longer exists in `src` before the
59
+ // refresh copy. `copyDir` only ever WRITES — it has no delete path — so a
60
+ // rule/reference file a newer skill version dropped would otherwise survive
61
+ // every future `update` forever, still sitting on disk and still read by the
62
+ // agent alongside the content that superseded it, while `update` reports a
63
+ // clean "already up to date" or a green "refreshed". Safe to prune
64
+ // unconditionally: `update` never touches a scope the caller didn't already
65
+ // have installed, and the refresh that follows overwrites everything that
66
+ // DOES still exist in `src` anyway (`--force`), so nothing reachable from the
67
+ // shipped skill is ever at risk — only content the shipped skill no longer
68
+ // ships is removed.
69
+ // `dryRun: true` counts what WOULD be removed without touching disk — the
70
+ // preview `--check` shows for the one destructive step `update` takes, so a
71
+ // dry run never has to say "outdated" and stay silent about a file the real
72
+ // run is about to delete.
73
+ function pruneRemoved(src, dest, { dryRun = false } = {}) {
74
+ if (!fs.existsSync(dest)) return 0;
75
+ let removed = 0;
76
+ for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
77
+ const destPath = path.join(dest, entry.name);
78
+ const srcPath = path.join(src, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
81
+ removed += pruneRemoved(srcPath, destPath, { dryRun });
82
+ } else {
83
+ if (!dryRun) fs.rmSync(destPath, { recursive: true, force: true });
84
+ removed++;
85
+ }
86
+ } else if (!fs.existsSync(srcPath)) {
87
+ if (!dryRun) fs.rmSync(destPath, { force: true });
88
+ removed++;
89
+ }
90
+ }
91
+ return removed;
92
+ }
93
+
94
+ const versionLabel = (v) => (v ? `v${v}` : 'unknown');
95
+
96
+ export async function update(args) {
97
+ const root = resolveProjectRoot(args.dir);
98
+ const dryRun = Boolean(args.check);
99
+
100
+ heading(dryRun ? 'LoreKit update (--check)' : 'LoreKit update');
101
+ log(` project: ${c.dim(root)}`);
102
+
103
+ const results = checkSkillVersions(root);
104
+ const scopes = targetScopes(args, results);
105
+
106
+ if (scopes.length === 0) {
107
+ log('');
108
+ log(` ${c.dim('no existing skill install found — nothing to update.')}`);
109
+ log(` Run ${c.cyan('lorekit install')} for a fresh install.`);
110
+ return { exitCode: 0, 'lorekit.cli.update.scopes': 0, 'lorekit.cli.update.outdated': 0, 'lorekit.cli.update.check': dryRun };
111
+ }
112
+
113
+ heading('Skills');
114
+ let outdatedCount = 0;
115
+ let filesWritten = 0;
116
+ let filesRemoved = 0;
117
+ for (const scope of scopes) {
118
+ for (const skill of SKILLS) {
119
+ const entry = results.find((r) => r.scope === scope && r.name === skill.name);
120
+ if (!entry || entry.state === 'not-installed') continue;
121
+ const label = scopes.length > 1 ? `skill ${skill.name} (${scope})` : `skill ${skill.name}`;
122
+
123
+ if (entry.state === 'current') {
124
+ status('pass', label, `${versionLabel(entry.installed)} — already up to date`);
125
+ continue;
126
+ }
127
+
128
+ outdatedCount++;
129
+ const before = versionLabel(entry.installed);
130
+ const after = versionLabel(entry.shipped);
131
+ const dest = skillInstallDir(root, scope, skill.name);
132
+ if (dryRun) {
133
+ const wouldRemove = pruneRemoved(skill.source, dest, { dryRun: true });
134
+ const removedNote = wouldRemove > 0 ? `, ${wouldRemove} to remove` : '';
135
+ status('warn', label, `${before} → ${after} available${removedNote} — run \`lorekit update\` to refresh`);
136
+ continue;
137
+ }
138
+
139
+ const removed = pruneRemoved(skill.source, dest);
140
+ const written = copyDir(skill.source, dest, { force: true });
141
+ filesWritten += written;
142
+ filesRemoved += removed;
143
+ const removedNote = removed > 0 ? `, ${removed} removed` : '';
144
+ status('pass', label, `${before} → ${after} (${written} file(s) written${removedNote})`);
145
+ }
146
+ }
147
+
148
+ // Hook command strings — refreshed for real, never on a dry run. Only a
149
+ // scope that already has hooks wired has anything to refresh; a scope with
150
+ // none stays untouched (matching `install`'s own "nothing to wire" no-op).
151
+ // `upsertClaudeHooks` rewrites `.claude/settings.json` unconditionally
152
+ // (formatting included) even when every entry was already `unchanged` —
153
+ // tallying its returned counts is what lets the no-skills-outdated report
154
+ // below say so instead of silently rewriting the file underneath the user.
155
+ let hooksChanged = 0;
156
+ if (!dryRun) {
157
+ for (const scope of scopes) {
158
+ const wired = installedHookEvents(root, scope);
159
+ if (wired.length === 0) continue;
160
+ const stats = upsertClaudeHooks(root, scope, resolveHookRunner(), wired);
161
+ hooksChanged += stats.added + stats.updated + stats.removed + stats.deduped;
162
+ }
163
+ }
164
+
165
+ log('');
166
+ if (outdatedCount === 0) {
167
+ const hooksNote = hooksChanged > 0 ? ` (hook wiring refreshed: ${hooksChanged} change${hooksChanged === 1 ? '' : 's'})` : '';
168
+ log(` ${c.green('✓')} every installed skill is already at the shipped version${hooksNote}.`);
169
+ } else if (dryRun) {
170
+ const plural = outdatedCount === 1 ? '' : 's';
171
+ log(` ${c.yellow('!')} ${outdatedCount} skill install${plural} outdated — run \`lorekit update\` to apply.`);
172
+ } else {
173
+ const plural = outdatedCount === 1 ? '' : 's';
174
+ const removedNote = filesRemoved > 0 ? `, ${filesRemoved} removed` : '';
175
+ log(` ${c.green('✓')} refreshed ${outdatedCount} skill install${plural} (${filesWritten} file(s) written${removedNote}).`);
176
+ }
177
+ log('');
178
+
179
+ return {
180
+ // Non-zero only for a `--check` run that found drift — a real run always
181
+ // finishes at 0 (it just fixed whatever it found), matching `doctor`'s own
182
+ // deliberate warn-never-fail posture. This is what lets `--check` gate a
183
+ // CI step or pre-commit hook on "everything is current" instead of only
184
+ // ever reporting drift for a human to notice.
185
+ exitCode: dryRun && outdatedCount > 0 ? 1 : 0,
186
+ 'lorekit.cli.update.scopes': scopes.length,
187
+ 'lorekit.cli.update.outdated': outdatedCount,
188
+ 'lorekit.cli.update.check': dryRun,
189
+ };
190
+ }
package/src/commands.mjs CHANGED
@@ -36,6 +36,7 @@
36
36
  import { install } from './commands/install.mjs';
37
37
  import { uninstall } from './commands/uninstall.mjs';
38
38
  import { doctor } from './commands/doctor.mjs';
39
+ import { update } from './commands/update.mjs';
39
40
  import { list } from './commands/list.mjs';
40
41
  import { search } from './commands/search.mjs';
41
42
  import { show } from './commands/show.mjs';
@@ -74,6 +75,7 @@ export const COMMANDS = [
74
75
  { name: 'install', run: install, traced: true, strictFlags: true, native: 'scaffolds skills, hooks and MCP config on disk' },
75
76
  { name: 'uninstall', run: uninstall, traced: true, strictFlags: true, native: 'removes what install wrote' },
76
77
  { name: 'doctor', run: doctor, traced: true, strictFlags: true, native: 'connectivity / token / scope health check' },
78
+ { name: 'update', run: update, traced: true, strictFlags: true, native: 'offline refresh of the bundled skills + hook wiring' },
77
79
  { name: 'list', run: list, traced: true, strictFlags: true, tool: 'memory.list', aliases: ['ls'] },
78
80
  { name: 'search', run: search, traced: true, strictFlags: true, tool: 'memory.search', aliases: ['grep'] },
79
81
  { name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' },