@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.
- package/README.md +24 -2
- package/bin/lorekit.mjs +36 -1
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +1 -1
- package/skill/lorekit-groom/rules/grooming-pass.md +34 -0
- package/skill/lorekit-memory/SKILL.md +1 -1
- package/skill/lorekit-setup/SKILL.md +141 -104
- package/skill/lorekit-setup/rules/cold-start-seeding.md +102 -0
- package/skill/lorekit-setup/rules/loop-health.md +117 -0
- package/skill/lorekit-setup/rules/proving-improvement.md +111 -0
- package/skill/lorekit-setup/rules/self-improvement-loops.md +52 -4
- package/skill/lorekit-setup/rules/team-and-portfolio.md +107 -0
- package/skill/lorekit-setup/templates/README.md +26 -0
- package/skill/lorekit-setup/templates/ci-job.md +90 -0
- package/skill/lorekit-setup/templates/code-changing-agent.md +89 -0
- package/skill/lorekit-setup/templates/multi-step-orchestrator.md +76 -0
- package/skill/lorekit-setup/templates/reviewer-reconcile-host.md +86 -0
- package/src/commands/doctor.mjs +41 -11
- package/src/commands/hook.mjs +22 -5
- package/src/commands/lint.mjs +4 -2
- package/src/commands/update.mjs +190 -0
- package/src/commands.mjs +2 -0
- package/src/core/update-notify.mjs +154 -0
- package/src/shared/completions.mjs +3 -0
- package/src/shared/control.mjs +22 -0
- package/src/shared/lessons-view.mjs +71 -0
- package/src/shared/skill-versions.mjs +148 -0
|
@@ -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.
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
package/src/commands/hook.mjs
CHANGED
|
@@ -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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/lint.mjs
CHANGED
|
@@ -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
|
|
5
|
-
// `
|
|
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' },
|