@codyswann/lisa 4.4.7 → 4.4.9

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.
Files changed (63) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +6 -3
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  8. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  9. package/plugins/lisa-agy/plugin.json +1 -1
  10. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  61. package/scripts/lib/plugin-cache-resolution.mjs +325 -0
  62. package/scripts/plugin-parity-drift.mjs +20 -253
  63. package/scripts/plugin-routing-validate.mjs +32 -68
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -341,6 +341,32 @@ auto-merge armed (section 1); when it returns, re-read `headRefOid` and reset
341
341
  `verify_commit` to the returned/pushed head, then continue. Do not re-implement review handling here
342
342
  — it is the single source of truth for review-thread handling.
343
343
 
344
+ **Merging past a rate-limited CodeRabbit is permitted — but ONLY when CodeRabbit
345
+ is the sole gate still blocking the merge.** When the CodeRabbit context reports
346
+ `Review rate limited` (or stays pending/queued because of the vendor cap) and it
347
+ is the *only* thing standing between the PR and `MERGED`, do not wait it out:
348
+ with `auto_merge=true` the PR already has auto-merge enabled (section 1), so
349
+ leave the latch armed and merge directly with `gh pr merge <pr> --<merge_method>`
350
+ (pass `--admin` only if branch protection lists the rate-limited context as
351
+ required and refuses the plain merge). "Sole gate" means every one of these is
352
+ already true at the moment you merge — verify each against the live poll, never
353
+ from memory:
354
+
355
+ - every other required check in `statusCheckRollup` is green (no FAILURE, no
356
+ other PENDING);
357
+ - zero unresolved review threads (human or bot);
358
+ - `reviewDecision` is not `CHANGES_REQUESTED`;
359
+ - `mergeable == MERGEABLE` and `mergeStateStatus` is not `BEHIND`/`DIRTY`;
360
+ - no pending auto-fix PR into this branch (step f).
361
+
362
+ If *anything* else is also blocking, the exception does not apply: clear that
363
+ blocker through its own step first, re-poll, and only then re-evaluate whether
364
+ CodeRabbit is the last gate standing. The exception never stacks with another
365
+ bypass, never fires under `auto_merge=false` (that mode stops at
366
+ `awaiting-human`, and a human decides), and never fires in `on_blocker=report`
367
+ mode. Record the result as `MERGED — NOT REVIEWED: CodeRabbit rate limited
368
+ (merged past as sole remaining gate)` in the terminal report (section 4).
369
+
344
370
  ### e. Review gate stall (`reviewDecision == CHANGES_REQUESTED`)
345
371
  After the requested changes are addressed and threads resolved, the prior
346
372
  `CHANGES_REQUESTED` review still blocks — a later `COMMENTED` review does not clear
@@ -541,7 +567,9 @@ Loop until one of:
541
567
  - **Hard block needing a human**: an unresolvable conflict, a failing check that
542
568
  needs design input, or genuine unresolved human objection (not a bot gate). Stop
543
569
  and report exactly what is blocking and what was already tried — never force the
544
- merge or weaken a gate to get past it.
570
+ merge or weaken a gate to get past it. The one sanctioned exception is a
571
+ rate-limited CodeRabbit that is the *sole* remaining gate on an auto-merge
572
+ enabled PR (step d); it never extends to any other gate.
545
573
 
546
574
  At every terminal state, release the babysitter lease
547
575
  (`gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"`) so the CI
@@ -556,6 +584,7 @@ So state the verdict alongside the outcome:
556
584
 
557
585
  - `MERGED — reviewed (CodeRabbit "Review approved")`
558
586
  - `MERGED — NOT REVIEWED: CodeRabbit posted success but "Review rate limited"`
587
+ - `MERGED — NOT REVIEWED: CodeRabbit rate limited (merged past as sole remaining gate)`
559
588
 
560
589
  This is reporting, never a terminal state of its own. `NOT REVIEWED` does not
561
590
  turn a merged PR into a blocked one, and it must never be used to withhold a
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The one resolver for "what version of this plugin is installed right now".
3
+ *
4
+ * It lives here rather than in either caller because there used to be two of
5
+ * them, and the second one was invisible. `plugin-routing-validate.mjs`
6
+ * imported `compareSemver` and `isValidSemver` from `plugin-parity-drift.mjs`
7
+ * and then walked the cache itself — so the two scripts LOOKED like one
8
+ * implementation, shared a module, and disagreed about the answer.
9
+ *
10
+ * That is worse than an ordinary duplicate. When the orphan defect was fixed in
11
+ * the detector (CodySwannGT/lisa#3085), the fix did not reach the validator, and
12
+ * the next person to hit it would have found a detector whose orphan handling
13
+ * was correct and tested, an import linking the failing script to it, and a
14
+ * failure the fix was supposed to have prevented. **The remedy appears to have
15
+ * been applied.** A fix that is present, correct, and unreachable costs more
16
+ * than no fix at all, because it argues the cause is somewhere else
17
+ * (CodySwannGT/lisa#3093).
18
+ *
19
+ * So: one implementation, one place to fix, and the import stops being
20
+ * misleading. The single genuine difference between the two callers is now a
21
+ * NAMED option rather than an accidental divergence — see `dirNameFallback`.
22
+ *
23
+ * ## What the cache actually is
24
+ *
25
+ * Append-mostly. Uninstalling a plugin does not delete its version directories;
26
+ * it stamps each one with `.orphaned_at`. The directories on disk are a record
27
+ * of every version ever fetched, not of what is installed now, and reading them
28
+ * as the latter is how a pin came to be compared against ten-day-old leftovers.
29
+ *
30
+ * Determinism: Node built-ins only, no network, no `Date`, no `Math.random`.
31
+ * @module scripts/lib/plugin-cache-resolution
32
+ */
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ // Literals named once — each was repeated enough times that a typo in one
37
+ // copy would diverge silently.
38
+ const NOT_INSTALLED = "not-installed";
39
+
40
+ /**
41
+ * Semver 2.0.0 grammar, one clause per name. Build metadata (`+...`) is
42
+ * accepted but ignored in comparison; prerelease (`-...`) is accepted and sorts
43
+ * below its release.
44
+ *
45
+ * Assembled from fragments rather than written as one literal because the
46
+ * literal was unreadable — this is the semver.org grammar verbatim, and the
47
+ * composed `.source` is byte-identical to the literal it replaced.
48
+ */
49
+ const SEMVER_NUMERIC = "0|[1-9]\\d*";
50
+ const SEMVER_PRERELEASE_ID = `(?:${SEMVER_NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
51
+ const SEMVER_PRERELEASE = `(?:-(${SEMVER_PRERELEASE_ID}(?:\\.${SEMVER_PRERELEASE_ID})*))?`;
52
+ const SEMVER_BUILD = "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
53
+ const SEMVER_RE = new RegExp(
54
+ `^(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})${SEMVER_PRERELEASE}${SEMVER_BUILD}$`
55
+ );
56
+
57
+ /** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
58
+ /**
59
+ * A single path token: no `.`, no `/`, so it cannot escape the cache root.
60
+ *
61
+ * Exported because both callers validate the same shape before building a cache
62
+ * path, and two copies of a path-traversal guard is exactly the duplication
63
+ * this module exists to end.
64
+ */
65
+ export const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
66
+
67
+ /**
68
+ * True iff `value` is a valid semver 2.0.0 string.
69
+ *
70
+ * @param {unknown} value - candidate version string.
71
+ * @returns {boolean} whether `value` parses as semver.
72
+ */
73
+ export function isValidSemver(value) {
74
+ if (typeof value !== "string") {
75
+ return false;
76
+ }
77
+ return SEMVER_RE.test(value);
78
+ }
79
+
80
+ /**
81
+ * Split a semver string into its numeric `[major, minor, patch]` core and the
82
+ * raw prerelease string (build metadata stripped).
83
+ *
84
+ * @param {string} version - a valid semver string.
85
+ * @returns {{ core: readonly number[], prerelease: string }} parsed parts.
86
+ */
87
+ function splitSemver(version) {
88
+ const withoutBuild = version.split("+", 1)[0];
89
+ const dashIndex = withoutBuild.indexOf("-");
90
+ const coreStr =
91
+ dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
92
+ const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1);
93
+ const core = coreStr.split(".").map(part => Number.parseInt(part, 10));
94
+ return { core, prerelease };
95
+ }
96
+
97
+ /**
98
+ * Compare two prerelease strings per semver precedence rules.
99
+ *
100
+ * @param {string} a - first prerelease (may be empty = "is a release").
101
+ * @param {string} b - second prerelease (may be empty = "is a release").
102
+ * @returns {number} -1, 0, or 1.
103
+ */
104
+ function comparePrerelease(a, b) {
105
+ if (a === b) {
106
+ return 0;
107
+ }
108
+ if (a === "") {
109
+ return 1; // a is a full release; it outranks any prerelease b.
110
+ }
111
+ if (b === "") {
112
+ return -1;
113
+ }
114
+ const aIds = a.split(".");
115
+ const bIds = b.split(".");
116
+ for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
117
+ const ai = aIds[i];
118
+ const bi = bIds[i];
119
+ if (ai === undefined) {
120
+ return -1; // shorter set of identifiers has lower precedence.
121
+ }
122
+ if (bi === undefined) {
123
+ return 1;
124
+ }
125
+ const aNum = /^\d+$/.test(ai);
126
+ const bNum = /^\d+$/.test(bi);
127
+ if (aNum && bNum) {
128
+ const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
129
+ if (diff !== 0) {
130
+ return diff < 0 ? -1 : 1;
131
+ }
132
+ continue;
133
+ }
134
+ if (aNum !== bNum) {
135
+ return aNum ? -1 : 1; // numeric identifiers rank below alphanumeric.
136
+ }
137
+ if (ai !== bi) {
138
+ return ai < bi ? -1 : 1;
139
+ }
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ /**
145
+ * Compare two semver strings. Build metadata is ignored; a prerelease sorts
146
+ * below its associated release.
147
+ *
148
+ * @param {string} a - first valid semver string.
149
+ * @param {string} b - second valid semver string.
150
+ * @returns {number} -1 if a < b, 0 if equal precedence, 1 if a > b.
151
+ */
152
+ export function compareSemver(a, b) {
153
+ const pa = splitSemver(a);
154
+ const pb = splitSemver(b);
155
+ for (let i = 0; i < 3; i++) {
156
+ const diff = pa.core[i] - pb.core[i];
157
+ if (diff !== 0) {
158
+ return diff < 0 ? -1 : 1;
159
+ }
160
+ }
161
+ return comparePrerelease(pa.prerelease, pb.prerelease);
162
+ }
163
+
164
+ /**
165
+ * True iff `target` is an existing directory.
166
+ *
167
+ * @param {string} target - filesystem path.
168
+ * @returns {boolean} whether `target` resolves to a directory.
169
+ */
170
+ export function isDirectory(target) {
171
+ try {
172
+ return fs.statSync(target).isDirectory();
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
180
+ *
181
+ * @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
182
+ * @returns {string | null} the manifest version string, or `null`.
183
+ */
184
+ function readManifestVersion(manifestPath) {
185
+ try {
186
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
187
+ return typeof parsed.version === "string" ? parsed.version : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Whether a cached version directory has been ORPHANED — the plugin manager's
195
+ * marker for a version that is no longer installed or served.
196
+ *
197
+ * The cache is append-mostly: uninstalling a plugin does not delete its version
198
+ * directories, it stamps each one with `.orphaned_at`. So the directories on
199
+ * disk are a record of every version ever fetched, not of what is installed
200
+ * now, and reading them as the latter is how this script came to compare a pin
201
+ * against ten-day-old leftovers.
202
+ *
203
+ * @param {string} versionDir - absolute path to one cached version directory.
204
+ * @returns {boolean} true when the directory carries an orphan marker.
205
+ */
206
+ function isOrphanedVersion(versionDir) {
207
+ return fs.existsSync(path.join(versionDir, ".orphaned_at"));
208
+ }
209
+
210
+ /**
211
+ * Resolve the current upstream version of `name@marketplace` purely from the
212
+ * cache tree: the MAX valid semver across the immediate version subdirs that
213
+ * are still LIVE, read from each subdir's `.claude-plugin/plugin.json`
214
+ * `version` field. Non-semver dirs (`unknown`, git hashes) are skipped because
215
+ * the manifest version is what counts, and orphaned dirs are skipped because
216
+ * they are not installed.
217
+ *
218
+ * Orphans are filtered BEFORE the max, and the order is load-bearing.
219
+ * "Filter, then take the max" and "take the max, then check whether it is
220
+ * orphaned" are different functions, and they disagree exactly when the newest
221
+ * live version is older than an orphan:
222
+ *
223
+ * live 1.0.6 + orphaned 2.0.4
224
+ * filter-then-max -> 1.0.6 (correct: 1.0.6 IS installed)
225
+ * max-then-check -> not-installed (wrong)
226
+ *
227
+ * When nothing live remains, this reports `not-installed` — a status this
228
+ * script already has and already handles — rather than manufacturing a current
229
+ * version out of orphans. That is `core/apply-receipt`'s principle: an
230
+ * unresolvable state reports unresolvable, not half-understood.
231
+ *
232
+ * The failure this closes was not theoretical. Every one of the ten cached
233
+ * `safety-net` versions was orphaned in a single sweep, and the resulting
234
+ * manufactured comparison blocked every push from the checkout — in one
235
+ * direction, and then, after a pin was moved to satisfy it, in the other.
236
+ * A defect that produces two opposite plausible remedies is one where the
237
+ * remedy is in neither direction.
238
+ *
239
+ * ## The one difference between the two callers, named
240
+ *
241
+ * `dirNameFallback` exists because the routing validator has always accepted a
242
+ * semver DIRECTORY NAME when a live version's manifest carries no usable
243
+ * `version` — some plugins ship no manifest version but a semver-named dir —
244
+ * while the drift detector deliberately reads the manifest and nothing else,
245
+ * because a `synced-from` pin is compared against what the manifest declares.
246
+ *
247
+ * That difference is real and worth keeping. What was not worth keeping is that
248
+ * it lived in two separate directory walks, where nobody could see it was the
249
+ * ONLY difference. A named option can be read; an accidental divergence has to
250
+ * be discovered by diffing two functions, which is what
251
+ * CodySwannGT/lisa#3093 was filed about.
252
+ *
253
+ * The fallback applies only to LIVE directories. An orphaned `2.0.4` with no
254
+ * manifest is still not installed, whatever its name says.
255
+ * @param {string} cacheRoot - the installed-plugin cache root.
256
+ * @param {string} name - plugin name.
257
+ * @param {string} marketplace - marketplace id.
258
+ * @param {{ dirNameFallback?: boolean }} [options] - resolution options.
259
+ * @returns {{ status: "ok" | "not-installed" | "unresolved", version: string | null }}
260
+ * the resolution outcome.
261
+ */
262
+ export function resolveCurrentVersion(
263
+ cacheRoot,
264
+ name,
265
+ marketplace,
266
+ options = {}
267
+ ) {
268
+ const dirNameFallback = options.dirNameFallback === true;
269
+ // Defense-in-depth path-traversal guard: only single-token names/marketplaces
270
+ // (no `.`, `/`, `..`) can map to a cache subdir. parseSyncedFrom already
271
+ // enforces this, but resolveCurrentVersion is a public export that no longer
272
+ // co-locates with its validating caller.
273
+ if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
274
+ return { status: NOT_INSTALLED, version: null };
275
+ }
276
+ const dir = path.join(cacheRoot, marketplace, name);
277
+ if (!isDirectory(dir)) {
278
+ return { status: NOT_INSTALLED, version: null };
279
+ }
280
+ const versions = [];
281
+ // Counted separately from `versions`, because "nothing is installed" and
282
+ // "something is installed but I cannot read its version" are different
283
+ // answers and must not collapse into one. A live directory with an
284
+ // unparseable manifest is `unresolved`; no live directory at all is
285
+ // `not-installed`.
286
+ let liveDirs = 0;
287
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
288
+ if (!entry.isDirectory()) {
289
+ continue;
290
+ }
291
+ const versionDir = path.join(dir, entry.name);
292
+ // Before the manifest is even read: an orphaned directory is not an
293
+ // installed version, whatever its manifest claims.
294
+ if (isOrphanedVersion(versionDir)) {
295
+ continue;
296
+ }
297
+ liveDirs += 1;
298
+ const manifest = path.join(versionDir, ".claude-plugin", "plugin.json");
299
+ const version = readManifestVersion(manifest);
300
+ if (version !== null && isValidSemver(version)) {
301
+ versions.push(version);
302
+ } else if (dirNameFallback && isValidSemver(entry.name)) {
303
+ versions.push(entry.name);
304
+ }
305
+ }
306
+ if (versions.length === 0) {
307
+ // No live directory at all means the plugin is not installed — a known,
308
+ // answerable state, and the one the whole orphan filter exists to reach.
309
+ // But a live directory whose manifest would not parse is still INSTALLED
310
+ // and merely unreadable, which is what `unresolved` has always meant.
311
+ // Collapsing the two would answer "I cannot read this version" with "this
312
+ // is not here", and an operator would go looking for the wrong thing.
313
+ return liveDirs === 0
314
+ ? { status: NOT_INSTALLED, version: null }
315
+ : { status: "unresolved", version: null };
316
+ }
317
+ // `versions` is non-empty (guarded above), so seeding with the first element
318
+ // is exactly what the no-seed form did — and it cannot throw if that guard is
319
+ // ever moved.
320
+ const max = versions.reduce(
321
+ (acc, v) => (compareSemver(v, acc) > 0 ? v : acc),
322
+ versions[0]
323
+ );
324
+ return { status: "ok", version: max };
325
+ }