@codyswann/lisa 2.124.11 → 2.125.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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  3. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  4. package/plugins/lisa-agy/plugin.json +1 -1
  5. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-expo-agy/plugin.json +1 -1
  15. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  25. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  30. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-rails-agy/plugin.json +1 -1
  35. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  40. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  45. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  47. package/scripts/plugin-parity-drift.mjs +608 -0
package/package.json CHANGED
@@ -83,7 +83,7 @@
83
83
  "lodash": ">=4.18.1"
84
84
  },
85
85
  "name": "@codyswann/lisa",
86
- "version": "2.124.11",
86
+ "version": "2.125.0",
87
87
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
88
88
  "main": "dist/index.js",
89
89
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
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-rails",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
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-rails",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
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-rails",
3
- "version": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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": "2.124.11",
3
+ "version": "2.125.0",
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"
@@ -0,0 +1,608 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic drift detector for Lisa's third-party plugin parity subsystem
4
+ * (issue #1059).
5
+ *
6
+ * A Lisa-native skill that reimplements an upstream Claude plugin carries a
7
+ * `synced-from: <name>@<marketplace>@<version>` frontmatter pin. This script
8
+ * scans for those pins, resolves each plugin's *current* upstream version from
9
+ * the installed plugin cache, and reports whether the Lisa reimplementation has
10
+ * drifted behind (or ahead of) its upstream. It NEVER auto-bumps and never
11
+ * edits any skill — it only reports, so CI can gate on its exit code.
12
+ *
13
+ * Design: parity/DESIGN-plugin-parity-subsystem.md §2–§3.
14
+ *
15
+ * Determinism guarantees (so the unit test is reproducible and CI is stable):
16
+ * - zero dependencies (Node built-ins only),
17
+ * - no network access,
18
+ * - no `Date` / `Math.random` — "current upstream" is defined purely as the
19
+ * MAX valid semver across the cache version subdirs (read from each
20
+ * manifest's `version` field, not the directory name).
21
+ *
22
+ * The cache root and skills roots are injectable via flags so tests point at a
23
+ * committed fixture instead of the machine's real `~/.claude/plugins/cache`.
24
+ *
25
+ * CLI:
26
+ * node scripts/plugin-parity-drift.mjs [--skills-root <dir>]... [--cache-root <dir>] [--json]
27
+ *
28
+ * Exit codes (CI contract, §3.4):
29
+ * 0 — no drift: every synced skill is `ok` (also when there are zero synced
30
+ * skills, in which case the cache need not exist — a fresh CI runner with
31
+ * no reimplementations passes cleanly).
32
+ * 1 — drift found: ≥1 skill is stale/ahead/not-installed/unresolved/unparseable.
33
+ * 2 — operational/usage error: unknown flag, a flag missing its value, no
34
+ * resolvable skills root, a filesystem error during the scan, or — only
35
+ * when ≥1 synced skill must be resolved — a missing cache root.
36
+ *
37
+ * @module scripts/plugin-parity-drift
38
+ */
39
+ import fs from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import process from "node:process";
43
+ import { fileURLToPath, pathToFileURL } from "node:url";
44
+
45
+ const REPO_ROOT = path.resolve(
46
+ path.dirname(fileURLToPath(import.meta.url)),
47
+ ".."
48
+ );
49
+
50
+ /**
51
+ * Semver 2.0.0 grammar. Build metadata (`+...`) is accepted but ignored in
52
+ * comparison; prerelease (`-...`) is accepted and sorts below its release.
53
+ */
54
+ const SEMVER_RE =
55
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
56
+
57
+ /** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
58
+ const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
59
+
60
+ /**
61
+ * Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
62
+ * distinguish it (exit 2) from a drift result (exit 1).
63
+ */
64
+ export class UsageError extends Error {}
65
+
66
+ /**
67
+ * True iff `value` is a valid semver 2.0.0 string.
68
+ *
69
+ * @param {unknown} value - candidate version string.
70
+ * @returns {boolean} whether `value` parses as semver.
71
+ */
72
+ export function isValidSemver(value) {
73
+ if (typeof value !== "string") {
74
+ return false;
75
+ }
76
+ return SEMVER_RE.test(value);
77
+ }
78
+
79
+ /**
80
+ * Split a semver string into its numeric `[major, minor, patch]` core and the
81
+ * raw prerelease string (build metadata stripped).
82
+ *
83
+ * @param {string} version - a valid semver string.
84
+ * @returns {{ core: readonly number[], prerelease: string }} parsed parts.
85
+ */
86
+ function splitSemver(version) {
87
+ const withoutBuild = version.split("+", 1)[0];
88
+ const dashIndex = withoutBuild.indexOf("-");
89
+ const coreStr =
90
+ dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
91
+ const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1);
92
+ const core = coreStr.split(".").map(part => Number.parseInt(part, 10));
93
+ return { core, prerelease };
94
+ }
95
+
96
+ /**
97
+ * Compare two prerelease strings per semver precedence rules.
98
+ *
99
+ * @param {string} a - first prerelease (may be empty = "is a release").
100
+ * @param {string} b - second prerelease (may be empty = "is a release").
101
+ * @returns {number} -1, 0, or 1.
102
+ */
103
+ function comparePrerelease(a, b) {
104
+ if (a === b) {
105
+ return 0;
106
+ }
107
+ if (a === "") {
108
+ return 1; // a is a full release; it outranks any prerelease b.
109
+ }
110
+ if (b === "") {
111
+ return -1;
112
+ }
113
+ const aIds = a.split(".");
114
+ const bIds = b.split(".");
115
+ for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
116
+ const ai = aIds[i];
117
+ const bi = bIds[i];
118
+ if (ai === undefined) {
119
+ return -1; // shorter set of identifiers has lower precedence.
120
+ }
121
+ if (bi === undefined) {
122
+ return 1;
123
+ }
124
+ const aNum = /^\d+$/.test(ai);
125
+ const bNum = /^\d+$/.test(bi);
126
+ if (aNum && bNum) {
127
+ const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
128
+ if (diff !== 0) {
129
+ return diff < 0 ? -1 : 1;
130
+ }
131
+ continue;
132
+ }
133
+ if (aNum !== bNum) {
134
+ return aNum ? -1 : 1; // numeric identifiers rank below alphanumeric.
135
+ }
136
+ if (ai !== bi) {
137
+ return ai < bi ? -1 : 1;
138
+ }
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ /**
144
+ * Compare two semver strings. Build metadata is ignored; a prerelease sorts
145
+ * below its associated release.
146
+ *
147
+ * @param {string} a - first valid semver string.
148
+ * @param {string} b - second valid semver string.
149
+ * @returns {number} -1 if a < b, 0 if equal precedence, 1 if a > b.
150
+ */
151
+ export function compareSemver(a, b) {
152
+ const pa = splitSemver(a);
153
+ const pb = splitSemver(b);
154
+ for (let i = 0; i < 3; i++) {
155
+ const diff = pa.core[i] - pb.core[i];
156
+ if (diff !== 0) {
157
+ return diff < 0 ? -1 : 1;
158
+ }
159
+ }
160
+ return comparePrerelease(pa.prerelease, pb.prerelease);
161
+ }
162
+
163
+ /**
164
+ * Parse a `synced-from` value of the form `name@marketplace@version`.
165
+ *
166
+ * Semver never contains `@`, so the version is everything right of the LAST
167
+ * `@`; the remainder is the canonical plugin id `name@marketplace`, split once
168
+ * more on its single `@`.
169
+ *
170
+ * @param {unknown} raw - the raw frontmatter value.
171
+ * @returns {{ name: string, marketplace: string, version: string, plugin: string } | null}
172
+ * parsed reference, or `null` if malformed.
173
+ */
174
+ export function parseSyncedFrom(raw) {
175
+ if (typeof raw !== "string") {
176
+ return null;
177
+ }
178
+ const value = raw.trim();
179
+ const lastAt = value.lastIndexOf("@");
180
+ if (lastAt <= 0 || lastAt === value.length - 1) {
181
+ return null;
182
+ }
183
+ const version = value.slice(lastAt + 1);
184
+ const pluginRef = value.slice(0, lastAt);
185
+ if (!isValidSemver(version)) {
186
+ return null;
187
+ }
188
+ const refAt = pluginRef.indexOf("@");
189
+ if (refAt <= 0 || refAt === pluginRef.length - 1) {
190
+ return null;
191
+ }
192
+ const name = pluginRef.slice(0, refAt);
193
+ const marketplace = pluginRef.slice(refAt + 1);
194
+ if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
195
+ return null;
196
+ }
197
+ return { marketplace, name, plugin: pluginRef, version };
198
+ }
199
+
200
+ /**
201
+ * Parse a minimal YAML frontmatter block (leading `--- ... ---`) into a flat
202
+ * map of `key: value` strings. Surrounding quotes are stripped. This is
203
+ * intentionally tiny — the detector only needs the `synced-from` scalar.
204
+ *
205
+ * @param {string} content - full file contents.
206
+ * @returns {Record<string, string>} the parsed frontmatter keys.
207
+ */
208
+ export function parseFrontmatter(content) {
209
+ const text = String(content);
210
+ if (!text.startsWith("---")) {
211
+ return {};
212
+ }
213
+ const end = text.indexOf("\n---", 3);
214
+ if (end === -1) {
215
+ return {};
216
+ }
217
+ const block = text.slice(text.indexOf("\n") + 1, end);
218
+ const result = {};
219
+ for (const line of block.split("\n")) {
220
+ const trimmed = line.trim();
221
+ if (trimmed === "" || trimmed.startsWith("#")) {
222
+ continue;
223
+ }
224
+ const colon = trimmed.indexOf(":");
225
+ if (colon === -1) {
226
+ continue;
227
+ }
228
+ const key = trimmed.slice(0, colon).trim();
229
+ const rawValue = trimmed.slice(colon + 1).trim();
230
+ const value = rawValue.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
231
+ result[key] = value;
232
+ }
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * True iff `target` is an existing directory.
238
+ *
239
+ * @param {string} target - filesystem path.
240
+ * @returns {boolean} whether `target` resolves to a directory.
241
+ */
242
+ function isDirectory(target) {
243
+ try {
244
+ return fs.statSync(target).isDirectory();
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
252
+ *
253
+ * @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
254
+ * @returns {string | null} the manifest version string, or `null`.
255
+ */
256
+ function readManifestVersion(manifestPath) {
257
+ try {
258
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
259
+ return typeof parsed.version === "string" ? parsed.version : null;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Resolve the current upstream version of `name@marketplace` purely from the
267
+ * cache tree: the MAX valid semver across the immediate version subdirs, read
268
+ * from each subdir's `.claude-plugin/plugin.json` `version` field. Non-semver
269
+ * dirs (`unknown`, git hashes) are skipped because the manifest version is what
270
+ * counts.
271
+ *
272
+ * @param {string} cacheRoot - the installed-plugin cache root.
273
+ * @param {string} name - plugin name.
274
+ * @param {string} marketplace - marketplace id.
275
+ * @returns {{ status: "ok" | "not-installed" | "unresolved", version: string | null }}
276
+ * the resolution outcome.
277
+ */
278
+ export function resolveCurrentVersion(cacheRoot, name, marketplace) {
279
+ // Defense-in-depth path-traversal guard: only single-token names/marketplaces
280
+ // (no `.`, `/`, `..`) can map to a cache subdir. parseSyncedFrom already
281
+ // enforces this, but resolveCurrentVersion is a public export that no longer
282
+ // co-locates with its validating caller.
283
+ if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
284
+ return { status: "not-installed", version: null };
285
+ }
286
+ const dir = path.join(cacheRoot, marketplace, name);
287
+ if (!isDirectory(dir)) {
288
+ return { status: "not-installed", version: null };
289
+ }
290
+ const versions = [];
291
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
292
+ if (!entry.isDirectory()) {
293
+ continue;
294
+ }
295
+ const manifest = path.join(
296
+ dir,
297
+ entry.name,
298
+ ".claude-plugin",
299
+ "plugin.json"
300
+ );
301
+ const version = readManifestVersion(manifest);
302
+ if (version !== null && isValidSemver(version)) {
303
+ versions.push(version);
304
+ }
305
+ }
306
+ if (versions.length === 0) {
307
+ return { status: "unresolved", version: null };
308
+ }
309
+ const max = versions.reduce((acc, v) =>
310
+ compareSemver(v, acc) > 0 ? v : acc
311
+ );
312
+ return { status: "ok", version: max };
313
+ }
314
+
315
+ /**
316
+ * Classify a synced skill by comparing its pinned version to the resolved
317
+ * current version (§3.3).
318
+ *
319
+ * @param {string} pinnedVersion - the `synced-from` pinned semver.
320
+ * @param {{ status: string, version: string | null }} cur - resolver output.
321
+ * @returns {"ok" | "stale" | "ahead" | "not-installed" | "unresolved"} the status.
322
+ */
323
+ export function classify(pinnedVersion, cur) {
324
+ if (cur.status === "not-installed") {
325
+ return "not-installed";
326
+ }
327
+ // Defense-in-depth: any non-`ok` resolver state, or an `ok` state without a
328
+ // string version, is treated as `unresolved` so compareSemver is never called
329
+ // with a null/undefined operand. resolveCurrentVersion guarantees
330
+ // `ok` ⇒ string version, so this only fires on contract misuse.
331
+ if (cur.status !== "ok" || typeof cur.version !== "string") {
332
+ return "unresolved";
333
+ }
334
+ const cmp = compareSemver(cur.version, pinnedVersion);
335
+ if (cmp === 0) {
336
+ return "ok";
337
+ }
338
+ return cmp > 0 ? "stale" : "ahead";
339
+ }
340
+
341
+ /**
342
+ * Recursively collect every `SKILL.md` path beneath `root`.
343
+ *
344
+ * @param {string} root - a skills root directory.
345
+ * @returns {string[]} absolute paths to discovered SKILL.md files.
346
+ */
347
+ function walkSkillFiles(root) {
348
+ const out = [];
349
+ if (!isDirectory(root)) {
350
+ return out;
351
+ }
352
+ const stack = [root];
353
+ while (stack.length > 0) {
354
+ const dir = stack.pop();
355
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
356
+ const full = path.join(dir, entry.name);
357
+ if (entry.isDirectory()) {
358
+ stack.push(full);
359
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
360
+ out.push(full);
361
+ }
362
+ }
363
+ }
364
+ return out;
365
+ }
366
+
367
+ /**
368
+ * Render a filesystem path relative to the repo root using POSIX separators,
369
+ * falling back to the absolute path when the file lives outside the repo.
370
+ *
371
+ * @param {string} absPath - an absolute path.
372
+ * @returns {string} a stable, display-friendly path.
373
+ */
374
+ function displayPath(absPath) {
375
+ const rel = path.relative(REPO_ROOT, absPath);
376
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
377
+ return absPath;
378
+ }
379
+ return rel.split(path.sep).join("/");
380
+ }
381
+
382
+ /**
383
+ * Build one result row for a discovered synced skill.
384
+ *
385
+ * @param {{ file: string, raw: string }} skill - the skill file + raw pin.
386
+ * @param {string} cacheRoot - the installed-plugin cache root.
387
+ * @returns {{ skillPath: string, plugin: string, pinnedVersion: string | null, currentVersion: string | null, status: string }}
388
+ * the classified result.
389
+ */
390
+ function buildResult(skill, cacheRoot) {
391
+ const skillPath = displayPath(skill.file);
392
+ const ref = parseSyncedFrom(skill.raw);
393
+ if (ref === null) {
394
+ return {
395
+ currentVersion: null,
396
+ pinnedVersion: null,
397
+ plugin: skill.raw,
398
+ skillPath,
399
+ status: "unparseable",
400
+ };
401
+ }
402
+ const cur = resolveCurrentVersion(cacheRoot, ref.name, ref.marketplace);
403
+ return {
404
+ currentVersion: cur.version,
405
+ pinnedVersion: ref.version,
406
+ plugin: ref.plugin,
407
+ skillPath,
408
+ status: classify(ref.version, cur),
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Assemble the machine-readable report (§3.5).
414
+ *
415
+ * @param {ReadonlyArray<Record<string, unknown>>} results - per-skill results.
416
+ * @param {{ cacheRoot: string, skillsRoots: readonly string[] }} opts - options.
417
+ * @returns {Record<string, unknown>} the report object.
418
+ */
419
+ export function buildReport(results, opts) {
420
+ const ok = results.filter(r => r.status === "ok").length;
421
+ return {
422
+ cacheRoot: opts.cacheRoot,
423
+ results,
424
+ schemaVersion: 1,
425
+ skillsRoots: opts.skillsRoots,
426
+ summary: { drift: results.length - ok, ok, scanned: results.length },
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Render the human-readable markdown table + summary line.
432
+ *
433
+ * @param {{ results: ReadonlyArray<Record<string, unknown>>, summary: { scanned: number, drift: number } }} report - report object.
434
+ * @returns {string} the rendered table.
435
+ */
436
+ function humanTable(report) {
437
+ const header =
438
+ "| skill | plugin | pinned | current | status |\n" +
439
+ "| --- | --- | --- | --- | --- |";
440
+ const rows = report.results.map(
441
+ r =>
442
+ `| ${cell(r.skillPath)} | ${cell(r.plugin)} | ${cell(r.pinnedVersion)} | ${cell(r.currentVersion)} | ${cell(r.status)} |`
443
+ );
444
+ const summary = `\n${report.summary.drift} of ${report.summary.scanned} synced skills drifted`;
445
+ return [header, ...rows].join("\n") + summary;
446
+ }
447
+
448
+ /**
449
+ * Sanitize a value for a markdown-table cell: a `|` in an `unparseable` raw
450
+ * `synced-from` string would otherwise break the column layout, and newlines
451
+ * would split the row. Pipes are escaped and newlines collapsed to spaces.
452
+ *
453
+ * @param {string | null | undefined} value - the raw cell value.
454
+ * @returns {string} a table-safe string (`-` for null/undefined).
455
+ */
456
+ function cell(value) {
457
+ if (value === null || value === undefined) {
458
+ return "-";
459
+ }
460
+ return String(value).replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
461
+ }
462
+
463
+ /**
464
+ * Parse argv into resolved options. Throws `UsageError` on a bad invocation.
465
+ *
466
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
467
+ * @returns {{ skillsRoots: string[], cacheRoot: string, json: boolean }} options.
468
+ */
469
+ export function parseArgs(argv) {
470
+ const skillsRoots = [];
471
+ let cacheRoot = null;
472
+ let json = false;
473
+ for (let i = 0; i < argv.length; i++) {
474
+ const arg = argv[i];
475
+ if (arg === "--json") {
476
+ json = true;
477
+ } else if (arg === "--skills-root") {
478
+ const next = argv[i + 1];
479
+ if (next === undefined || next.startsWith("--")) {
480
+ throw new UsageError("--skills-root requires a value");
481
+ }
482
+ skillsRoots.push(next);
483
+ i += 1;
484
+ } else if (arg === "--cache-root") {
485
+ const next = argv[i + 1];
486
+ if (next === undefined || next.startsWith("--")) {
487
+ throw new UsageError("--cache-root requires a value");
488
+ }
489
+ cacheRoot = next;
490
+ i += 1;
491
+ } else {
492
+ throw new UsageError(`unknown argument: ${arg}`);
493
+ }
494
+ }
495
+ const resolvedCache =
496
+ cacheRoot ??
497
+ process.env.CLAUDE_PLUGIN_CACHE ??
498
+ path.join(os.homedir(), ".claude", "plugins", "cache");
499
+ const resolvedSkills =
500
+ skillsRoots.length > 0
501
+ ? skillsRoots
502
+ : [path.join(REPO_ROOT, ".claude", "skills")];
503
+ return {
504
+ cacheRoot: path.resolve(resolvedCache),
505
+ json,
506
+ skillsRoots: resolvedSkills.map(r => path.resolve(r)),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Scan the skills roots and collect every SKILL.md carrying a non-empty
512
+ * `synced-from` pin. May throw on a filesystem error (caller handles it).
513
+ *
514
+ * @param {readonly string[]} roots - resolved skills-root directories.
515
+ * @returns {{ file: string, raw: string }[]} discovered synced skills.
516
+ */
517
+ function collectSyncedSkills(roots) {
518
+ const skills = [];
519
+ // Sort roots and the final result by path so the report is deterministic:
520
+ // readdirSync() traversal order is filesystem-dependent, which would otherwise
521
+ // reshuffle rows across environments even for an unchanged skills set.
522
+ for (const root of [...roots].sort((a, b) => a.localeCompare(b))) {
523
+ for (const file of walkSkillFiles(root)) {
524
+ const frontmatter = parseFrontmatter(fs.readFileSync(file, "utf8"));
525
+ const raw = frontmatter["synced-from"];
526
+ if (typeof raw === "string" && raw !== "") {
527
+ skills.push({ file, raw });
528
+ }
529
+ }
530
+ }
531
+ return skills.sort((a, b) => a.file.localeCompare(b.file));
532
+ }
533
+
534
+ /**
535
+ * Render a report to the chosen stream.
536
+ *
537
+ * @param {{ write(s: string): void }} out - the output stream.
538
+ * @param {Record<string, unknown>} report - the report object.
539
+ * @param {boolean} json - whether to emit JSON instead of the human table.
540
+ * @returns {void}
541
+ */
542
+ function emitReport(out, report, json) {
543
+ out.write(
544
+ (json ? JSON.stringify(report, null, 2) : humanTable(report)) + "\n"
545
+ );
546
+ }
547
+
548
+ /**
549
+ * Run the detector. Returns the process exit code (does not call `exit`).
550
+ *
551
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
552
+ * @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
553
+ * injectable streams (defaults to process streams).
554
+ * @returns {number} the exit code (0 ok, 1 drift, 2 usage error).
555
+ */
556
+ export function main(argv, io = {}) {
557
+ const out = io.stdout ?? process.stdout;
558
+ const err = io.stderr ?? process.stderr;
559
+ let opts;
560
+ try {
561
+ opts = parseArgs(argv);
562
+ } catch (error) {
563
+ err.write(`error: ${error.message}\n`);
564
+ return 2;
565
+ }
566
+ if (!opts.skillsRoots.some(isDirectory)) {
567
+ err.write("error: no --skills-root resolved to a directory\n");
568
+ return 2;
569
+ }
570
+ // Scan for synced skills first. Wrapped in try/catch so a filesystem race
571
+ // (TOCTOU) during the walk/read surfaces as a clean usage error (exit 2)
572
+ // rather than an uncaught throw escaping main().
573
+ let skills;
574
+ try {
575
+ skills = collectSyncedSkills(opts.skillsRoots);
576
+ } catch (error) {
577
+ err.write(`error: failed to scan skills roots: ${error.message}\n`);
578
+ return 2;
579
+ }
580
+ // With zero reimplementations there is trivially no drift, so succeed even if
581
+ // the plugin cache is absent — a fresh CI runner with no synced skills (and
582
+ // no cache yet) must not fail the build.
583
+ if (skills.length === 0) {
584
+ emitReport(out, buildReport([], opts), opts.json);
585
+ return 0;
586
+ }
587
+ // There is ≥1 synced skill to resolve, so the cache is now required.
588
+ if (!isDirectory(opts.cacheRoot)) {
589
+ err.write(`error: --cache-root is not a directory: ${opts.cacheRoot}\n`);
590
+ return 2;
591
+ }
592
+ let results;
593
+ try {
594
+ results = skills.map(skill => buildResult(skill, opts.cacheRoot));
595
+ } catch (error) {
596
+ err.write(`error: failed to resolve plugin versions: ${error.message}\n`);
597
+ return 2;
598
+ }
599
+ emitReport(out, buildReport(results, opts), opts.json);
600
+ return results.every(r => r.status === "ok") ? 0 : 1;
601
+ }
602
+
603
+ if (
604
+ process.argv[1] &&
605
+ import.meta.url === pathToFileURL(process.argv[1]).href
606
+ ) {
607
+ process.exit(main(process.argv.slice(2)));
608
+ }