selftune 0.2.30 → 0.2.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ import { execFile } from "node:child_process";
2
+ import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { parseFrontmatter } from "../utils/frontmatter.js";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export interface GithubRegistryInstallTarget {
11
+ owner: string;
12
+ repo: string;
13
+ repoFullName: string;
14
+ ref: string | null;
15
+ skillPath: string | null;
16
+ }
17
+
18
+ function normalizeGithubSkillPath(skillPath: string): string {
19
+ const trimmed = skillPath.trim().replace(/\\/g, "/");
20
+ if (!trimmed || trimmed === ".") {
21
+ return ".";
22
+ }
23
+
24
+ const segments = trimmed.split("/").filter(Boolean);
25
+ if (segments.includes("..")) {
26
+ throw new Error("GitHub skill path must stay within the repository");
27
+ }
28
+
29
+ const normalized = path.posix.normalize(trimmed).replace(/^\/+|\/+$/g, "");
30
+ return normalized || ".";
31
+ }
32
+
33
+ export function parseGithubRegistryInstallTarget(
34
+ rawTarget: string,
35
+ ): GithubRegistryInstallTarget | null {
36
+ if (!rawTarget.startsWith("github:")) {
37
+ return null;
38
+ }
39
+
40
+ const spec = rawTarget.slice("github:".length).trim();
41
+ if (!spec) {
42
+ throw new Error("GitHub install target must be github:owner/repo[@ref][//path]");
43
+ }
44
+
45
+ const pathSeparatorIndex = spec.indexOf("//");
46
+ const repoWithMaybeRef = pathSeparatorIndex === -1 ? spec : spec.slice(0, pathSeparatorIndex);
47
+ const pathWithMaybeRef = pathSeparatorIndex === -1 ? null : spec.slice(pathSeparatorIndex + 2);
48
+
49
+ let ref: string | null = null;
50
+ let repoSpec = repoWithMaybeRef;
51
+
52
+ const repoRefIndex = repoWithMaybeRef.lastIndexOf("@");
53
+ if (repoRefIndex !== -1) {
54
+ repoSpec = repoWithMaybeRef.slice(0, repoRefIndex);
55
+ ref = repoWithMaybeRef.slice(repoRefIndex + 1) || null;
56
+ }
57
+
58
+ let skillPath: string | null = null;
59
+ if (pathWithMaybeRef != null) {
60
+ const pathRefIndex = pathWithMaybeRef.lastIndexOf("@");
61
+ if (pathRefIndex !== -1) {
62
+ skillPath = pathWithMaybeRef.slice(0, pathRefIndex) || ".";
63
+ ref = pathWithMaybeRef.slice(pathRefIndex + 1) || ref;
64
+ } else {
65
+ skillPath = pathWithMaybeRef || ".";
66
+ }
67
+ }
68
+
69
+ const match = repoSpec.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
70
+ if (!match) {
71
+ throw new Error("GitHub install target must look like github:owner/repo[@ref][//path]");
72
+ }
73
+
74
+ return {
75
+ owner: match[1],
76
+ repo: match[2],
77
+ repoFullName: `${match[1]}/${match[2]}`,
78
+ ref,
79
+ skillPath: skillPath ? normalizeGithubSkillPath(skillPath) : null,
80
+ };
81
+ }
82
+
83
+ function isExcludedEntry(name: string): boolean {
84
+ return name === ".git" || name === "node_modules" || name === ".env" || name.startsWith(".env.");
85
+ }
86
+
87
+ export async function discoverLocalSkillPaths(rootDir: string): Promise<string[]> {
88
+ async function walk(currentDir: string, basePath: string): Promise<string[]> {
89
+ const entries = await readdir(currentDir, { withFileTypes: true });
90
+ const discovered: string[] = [];
91
+
92
+ for (const entry of entries) {
93
+ if (isExcludedEntry(entry.name)) {
94
+ continue;
95
+ }
96
+
97
+ const fullPath = path.join(currentDir, entry.name);
98
+ const relativePath = basePath ? path.join(basePath, entry.name) : entry.name;
99
+
100
+ if (entry.isDirectory()) {
101
+ discovered.push(...(await walk(fullPath, relativePath)));
102
+ continue;
103
+ }
104
+
105
+ if (entry.isFile() && entry.name === "SKILL.md") {
106
+ discovered.push(basePath ? basePath.split(path.sep).join("/") : ".");
107
+ }
108
+ }
109
+
110
+ return discovered;
111
+ }
112
+
113
+ const discovered = await walk(rootDir, "");
114
+ return [...new Set(discovered)].sort((a, b) => a.localeCompare(b));
115
+ }
116
+
117
+ export async function resolveGithubSkillPath(
118
+ repoDir: string,
119
+ requestedSkillPath: string | null,
120
+ ): Promise<{ skillPath: string; availablePaths: string[] }> {
121
+ const availablePaths = await discoverLocalSkillPaths(repoDir);
122
+
123
+ if (requestedSkillPath) {
124
+ const normalized = normalizeGithubSkillPath(requestedSkillPath);
125
+ const skillMdPath =
126
+ normalized === "."
127
+ ? path.join(repoDir, "SKILL.md")
128
+ : path.join(repoDir, ...normalized.split("/"), "SKILL.md");
129
+ await stat(skillMdPath);
130
+ return { skillPath: normalized, availablePaths };
131
+ }
132
+
133
+ if (availablePaths.length === 1) {
134
+ return { skillPath: availablePaths[0] ?? ".", availablePaths };
135
+ }
136
+
137
+ if (availablePaths.length === 0) {
138
+ throw new Error("No SKILL.md found in the GitHub repository");
139
+ }
140
+
141
+ throw new Error(
142
+ `Multiple skills found in the GitHub repository. Choose one with github:owner/repo//path (available: ${availablePaths.join(", ")})`,
143
+ );
144
+ }
145
+
146
+ export function deriveGithubInstallSkillName(
147
+ frontmatterName: string,
148
+ skillPath: string,
149
+ skillDir: string,
150
+ repoName: string,
151
+ ): string {
152
+ const trimmedName = frontmatterName.trim();
153
+ if (trimmedName) {
154
+ return trimmedName;
155
+ }
156
+
157
+ return skillPath === "." ? repoName : path.basename(skillDir);
158
+ }
159
+
160
+ async function cloneGithubRepository(
161
+ target: GithubRegistryInstallTarget,
162
+ cloneDir: string,
163
+ ): Promise<void> {
164
+ const repoUrl = `https://github.com/${target.repoFullName}.git`;
165
+ const args = ["clone", "--depth=1"];
166
+
167
+ if (target.ref) {
168
+ args.push("--branch", target.ref);
169
+ }
170
+
171
+ args.push(repoUrl, cloneDir);
172
+
173
+ await execFileAsync("git", args);
174
+ }
175
+
176
+ async function copySkillDirectory(sourceDir: string, targetDir: string): Promise<void> {
177
+ await rm(targetDir, { recursive: true, force: true });
178
+ await mkdir(path.dirname(targetDir), { recursive: true });
179
+
180
+ await cp(sourceDir, targetDir, {
181
+ recursive: true,
182
+ filter: (entryPath) => {
183
+ const basename = path.basename(entryPath);
184
+ return !isExcludedEntry(basename);
185
+ },
186
+ });
187
+ }
188
+
189
+ export async function installFromGithubTarget(
190
+ rawTarget: string,
191
+ globalFlag: boolean,
192
+ ): Promise<void> {
193
+ const target = parseGithubRegistryInstallTarget(rawTarget);
194
+ if (!target) {
195
+ throw new Error("GitHub install target must start with github:");
196
+ }
197
+
198
+ const tempRoot = await mkdtemp(path.join(tmpdir(), "selftune-github-install-"));
199
+
200
+ try {
201
+ const cloneDir = path.join(tempRoot, "repo");
202
+ await cloneGithubRepository(target, cloneDir);
203
+
204
+ const { skillPath, availablePaths } = await resolveGithubSkillPath(cloneDir, target.skillPath);
205
+ const skillDir = skillPath === "." ? cloneDir : path.join(cloneDir, ...skillPath.split("/"));
206
+ const skillContent = await readFile(path.join(skillDir, "SKILL.md"), "utf-8");
207
+ const frontmatter = parseFrontmatter(skillContent);
208
+ const skillName = deriveGithubInstallSkillName(
209
+ frontmatter.name,
210
+ skillPath,
211
+ skillDir,
212
+ target.repo,
213
+ );
214
+ const resolvedCommit = (
215
+ await execFileAsync("git", ["-C", cloneDir, "rev-parse", "HEAD"])
216
+ ).stdout.trim();
217
+
218
+ const targetBase = globalFlag
219
+ ? path.join(process.env.HOME || "~", ".claude", "skills")
220
+ : path.join(process.cwd(), ".claude", "skills");
221
+ const targetDir = path.join(targetBase, skillName);
222
+
223
+ await copySkillDirectory(skillDir, targetDir);
224
+ await writeFile(
225
+ path.join(targetDir, ".selftune-source.json"),
226
+ JSON.stringify(
227
+ {
228
+ source: "github-direct",
229
+ repo: target.repoFullName,
230
+ ref: target.ref ?? "HEAD",
231
+ commit: resolvedCommit,
232
+ skill_path: skillPath,
233
+ available_paths: availablePaths,
234
+ },
235
+ null,
236
+ 2,
237
+ ),
238
+ );
239
+
240
+ console.log(
241
+ JSON.stringify({
242
+ success: true,
243
+ source: "github-direct",
244
+ name: skillName,
245
+ repo: target.repoFullName,
246
+ ref: target.ref ?? "HEAD",
247
+ commit: resolvedCommit,
248
+ skill_path: skillPath,
249
+ path: targetDir,
250
+ global: globalFlag,
251
+ }),
252
+ );
253
+ } finally {
254
+ await rm(tempRoot, { recursive: true, force: true });
255
+ }
256
+ }
@@ -24,7 +24,7 @@ Usage:
24
24
 
25
25
  Subcommands:
26
26
  push [name] Push current skill folder as a new version
27
- install <name> Download and install a skill from the registry
27
+ install <name> Download from the registry or install github:owner/repo[@ref][//path]
28
28
  sync Check for updates and pull latest versions
29
29
  status Show installed entries and version drift
30
30
  rollback <name> Rollback to a previous version
@@ -8,6 +8,7 @@ import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
9
 
10
10
  import { registryRequest } from "./client.js";
11
+ import { installFromGithubTarget, parseGithubRegistryInstallTarget } from "./github-install.js";
11
12
 
12
13
  export async function cliMain() {
13
14
  const args = process.argv.slice(2);
@@ -17,13 +18,45 @@ export async function cliMain() {
17
18
  if (!name) {
18
19
  console.error(
19
20
  JSON.stringify({
20
- error: "Usage: selftune registry install <name>",
21
+ error: "Usage: selftune registry install <name|github:owner/repo[@ref][//path]>",
21
22
  guidance: { next_command: "selftune registry list" },
22
23
  }),
23
24
  );
24
25
  process.exit(1);
25
26
  }
26
27
 
28
+ let githubTarget = null;
29
+ try {
30
+ githubTarget = parseGithubRegistryInstallTarget(name);
31
+ } catch (error) {
32
+ console.error(
33
+ JSON.stringify({
34
+ error: error instanceof Error ? error.message : "Invalid GitHub install target",
35
+ guidance: {
36
+ next_command: "selftune registry install github:owner/repo//path",
37
+ },
38
+ }),
39
+ );
40
+ process.exit(1);
41
+ }
42
+
43
+ if (githubTarget) {
44
+ try {
45
+ await installFromGithubTarget(name, globalFlag);
46
+ return;
47
+ } catch (error) {
48
+ console.error(
49
+ JSON.stringify({
50
+ error: error instanceof Error ? error.message : "GitHub install failed",
51
+ guidance: {
52
+ next_command: "selftune registry install github:owner/repo//path",
53
+ },
54
+ }),
55
+ );
56
+ process.exit(1);
57
+ }
58
+ }
59
+
27
60
  // Find entry by name
28
61
  const listResult = await registryRequest<{
29
62
  entries: Array<{
@@ -49,7 +82,12 @@ export async function cliMain() {
49
82
  // Get detail with versions
50
83
  const detailResult = await registryRequest<{
51
84
  entry: { id: string; name: string };
52
- versions: Array<{ id: string; version: string; content_hash: string; is_current: boolean }>;
85
+ versions: Array<{
86
+ id: string;
87
+ version: string;
88
+ content_hash: string;
89
+ is_current: boolean;
90
+ }>;
53
91
  }>("GET", `/${entryId}`);
54
92
 
55
93
  if (!detailResult.success) {
@@ -71,7 +109,9 @@ export async function cliMain() {
71
109
  latest_content_hash: string;
72
110
  }>;
73
111
  }>("POST", "/sync", {
74
- body: { installations: [{ entry_id: entryId, current_version_hash: "none" }] },
112
+ body: {
113
+ installations: [{ entry_id: entryId, current_version_hash: "none" }],
114
+ },
75
115
  });
76
116
 
77
117
  const downloadUrl = syncResult.data?.entries?.[0]?.download_url;
@@ -82,7 +122,9 @@ export async function cliMain() {
82
122
 
83
123
  // Download archive
84
124
  console.log(`Installing ${name} v${currentVersion.version}...`);
85
- const response = await fetch(downloadUrl, { signal: AbortSignal.timeout(60_000) });
125
+ const response = await fetch(downloadUrl, {
126
+ signal: AbortSignal.timeout(60_000),
127
+ });
86
128
  if (!response.ok) {
87
129
  console.error(JSON.stringify({ error: `Download failed: HTTP ${response.status}` }));
88
130
  process.exit(1);
@@ -119,13 +161,22 @@ export async function cliMain() {
119
161
 
120
162
  // Update local state
121
163
  const statePath = join(process.env.HOME || "~", ".selftune", "registry-state.json");
122
- let state: Array<{ entryId: string; name: string; versionHash: string; installPath: string }> =
123
- [];
164
+ let state: Array<{
165
+ entryId: string;
166
+ name: string;
167
+ versionHash: string;
168
+ installPath: string;
169
+ }> = [];
124
170
  try {
125
171
  state = JSON.parse(readFileSync(statePath, "utf-8"));
126
172
  } catch {}
127
173
  state = state.filter((s) => s.entryId !== entryId);
128
- state.push({ entryId, name, versionHash: currentVersion.content_hash, installPath: targetDir });
174
+ state.push({
175
+ entryId,
176
+ name,
177
+ versionHash: currentVersion.content_hash,
178
+ installPath: targetDir,
179
+ });
129
180
  await mkdir(join(process.env.HOME || "~", ".selftune"), { recursive: true });
130
181
  await writeFile(statePath, JSON.stringify(state, null, 2));
131
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "selftune",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically",
5
5
  "keywords": [
6
6
  "agent",
@@ -217,7 +217,7 @@ export const DASHBOARD_ROUTE_MANIFEST: readonly DashboardRouteManifestEntry[] =
217
217
  icon: PackageIcon,
218
218
  feature: "registry",
219
219
  discoverableFeature: "registry",
220
- lockedTitle: "Cloud Registry lives in Selftune Cloud",
220
+ lockedTitle: "Cloud Registry lives in selftune Cloud",
221
221
  lockedBody:
222
222
  "Publish versioned skills, watch installations across projects, and roll back bad versions from a single cloud workspace.",
223
223
  lockedHighlights: [
@@ -255,7 +255,7 @@ export const DASHBOARD_ROUTE_MANIFEST: readonly DashboardRouteManifestEntry[] =
255
255
  icon: UsersIcon,
256
256
  feature: "signals",
257
257
  discoverableFeature: "signals",
258
- lockedTitle: "Contributor signals run through Selftune Cloud",
258
+ lockedTitle: "Contributor signals run through selftune Cloud",
259
259
  lockedBody:
260
260
  "See anonymized contributor signals, compare bundle submissions, and turn real-world usage into proposals without leaving the shared dashboard.",
261
261
  lockedHighlights: [
@@ -379,7 +379,7 @@ function narrativeObservedText({
379
379
  promptLinkRate != null
380
380
  ? ` It could link ${formatRate(promptLinkRate)} of those checks back to prompts.`
381
381
  : "";
382
- return `Selftune watched ${checks} skill checks across ${sessions} sessions.${promptClause}`;
382
+ return `selftune watched ${checks} skill checks across ${sessions} sessions.${promptClause}`;
383
383
  }
384
384
 
385
385
  function narrativeDiagnosisText({
@@ -411,17 +411,17 @@ function narrativeDecisionText({
411
411
  }) {
412
412
  switch (trustState) {
413
413
  case "validated":
414
- return `Selftune found a candidate that looks promising, but it has not been deployed yet. ${nextActionText}`;
414
+ return `selftune found a candidate that looks promising, but it has not been deployed yet. ${nextActionText}`;
415
415
  case "deployed":
416
- return `A change has already been deployed for this skill. Selftune is now watching for regressions in real use.`;
416
+ return `A change has already been deployed for this skill. selftune is now watching for regressions in real use.`;
417
417
  case "rolled_back":
418
418
  return `A previous change was rolled back, so the live skill is back on the safer version while selftune keeps observing.`;
419
419
  case "watch":
420
- return `Selftune sees enough signal to keep a close eye on this skill, but not enough to blindly change it. ${nextActionText}`;
420
+ return `selftune sees enough signal to keep a close eye on this skill, but not enough to blindly change it. ${nextActionText}`;
421
421
  case "observed":
422
- return `Selftune is still learning how people use this skill before making stronger recommendations.`;
422
+ return `selftune is still learning how people use this skill before making stronger recommendations.`;
423
423
  case "low_sample":
424
- return `There is not enough evidence yet to trust a big change here. Selftune is still collecting examples.`;
424
+ return `There is not enough evidence yet to trust a big change here. selftune is still collecting examples.`;
425
425
  default:
426
426
  return latestAction
427
427
  ? `The latest automated decision for this skill was ${latestAction}. ${nextActionText}`
@@ -552,7 +552,7 @@ export function SkillTrustNarrativePanel({
552
552
  />
553
553
  </div>
554
554
  <div className="rounded-xl border border-border/10 bg-muted/15 px-4 py-3 text-sm text-muted-foreground">
555
- If a proposal is rejected or still pending, your live skill has not changed yet. Selftune
555
+ If a proposal is rejected or still pending, your live skill has not changed yet. selftune
556
556
  only earns trust by testing changes before deployment.
557
557
  </div>
558
558
  </CardContent>
@@ -18,6 +18,10 @@ const buttonVariants = cva(
18
18
  destructive:
19
19
  "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
20
  link: "text-primary underline-offset-4 hover:underline",
21
+ "glass-primary":
22
+ "border-cyan-300 bg-background/75 text-foreground shadow-[0_10px_28px_rgba(34,211,238,0.14),inset_0_1px_0_rgba(255,255,255,0.09)] hover:border-cyan-200 hover:bg-background/85 hover:shadow-[0_14px_34px_rgba(34,211,238,0.18),inset_0_1px_0_rgba(255,255,255,0.12)]",
23
+ "glass-secondary":
24
+ "border-border/70 bg-background/60 text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] hover:border-cyan-400/20 hover:bg-background/75 hover:shadow-[0_10px_24px_rgba(34,211,238,0.08),inset_0_1px_0_rgba(255,255,255,0.1)]",
21
25
  },
22
26
  size: {
23
27
  default:
@@ -31,6 +35,7 @@ const buttonVariants = cva(
31
35
  "icon-sm":
32
36
  "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
37
  "icon-lg": "size-9",
38
+ glass: "gap-2 px-4 py-2 backdrop-blur-sm",
34
39
  },
35
40
  },
36
41
  defaultVariants: {
package/skill/SKILL.md CHANGED
@@ -13,7 +13,7 @@ description: >
13
13
  even if they don't say "selftune" explicitly.
14
14
  metadata:
15
15
  author: selftune-dev
16
- version: 0.2.30
16
+ version: 0.2.31
17
17
  category: developer-tools
18
18
  ---
19
19
 
@@ -4,20 +4,21 @@ Manage versioned skill distribution across your team. Push skill folders to the
4
4
 
5
5
  ## Commands
6
6
 
7
- | Command | Flags | What It Does |
8
- |---------|-------|-------------|
9
- | `selftune registry push [name]` | `--version=<semver>` `--summary=<text>` | Archive current skill folder and push as a new version |
10
- | `selftune registry install <name>` | `--global` | Download and extract a skill from the registry |
11
- | `selftune registry sync` | | Check all installed entries for updates, pull latest |
12
- | `selftune registry status` | | Show installed entries with version drift |
13
- | `selftune registry rollback <name>` | `--to=<version>` `--reason=<text>` | Rollback a skill to a previous version |
14
- | `selftune registry history <name>` | | Show version timeline with quality data |
15
- | `selftune registry list` | | Show all published entries in the org |
7
+ | Command | Flags | What It Does |
8
+ | ------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------- |
9
+ | `selftune registry push [name]` | `--version=<semver>` `--summary=<text>` | Archive current skill folder and push as a new version |
10
+ | `selftune registry install <name\|github:owner/repo[@ref][//path]>` | `--global` | Download from the registry or clone/install directly from GitHub |
11
+ | `selftune registry sync` | | Check all installed entries for updates, pull latest |
12
+ | `selftune registry status` | | Show installed entries with version drift |
13
+ | `selftune registry rollback <name>` | `--to=<version>` `--reason=<text>` | Rollback a skill to a previous version |
14
+ | `selftune registry history <name>` | | Show version timeline with quality data |
15
+ | `selftune registry list` | | Show all published entries in the org |
16
16
 
17
17
  ## When to Use
18
18
 
19
19
  - User says "push this skill to the team" → `selftune registry push`
20
20
  - User says "install the deploy skill" → `selftune registry install deploy`
21
+ - User says "install this GitHub skill repo" → `selftune registry install github:owner/repo`
21
22
  - User says "update my skills" or "sync registry" → `selftune registry sync`
22
23
  - User says "check for updates" → `selftune registry status`
23
24
  - User says "rollback the deploy skill" → `selftune registry rollback deploy`
@@ -34,10 +35,13 @@ Manage versioned skill distribution across your team. Push skill folders to the
34
35
 
35
36
  ## Install Workflow
36
37
 
37
- 1. Run `selftune registry install <name>` to pull from the registry
38
+ 1. Run `selftune registry install <name>` to pull from the registry, or
39
+ `selftune registry install github:owner/repo[@ref][//path]` to clone and
40
+ install directly from GitHub using local git credentials
38
41
  2. By default, installs to `.claude/skills/<name>/` in the current project
39
42
  3. Use `--global` to install to `~/.claude/skills/<name>/` (available everywhere)
40
- 4. Installation is tracked `selftune registry status` shows what's installed
43
+ 4. Registry installs are tracked by `selftune registry status`; direct GitHub
44
+ installs are local-only and do not participate in `registry sync`
41
45
 
42
46
  ## Sync Workflow
43
47
 
@@ -82,8 +86,10 @@ All commands output JSON for agent consumption:
82
86
 
83
87
  **User wants to install a shared skill**
84
88
 
85
- > Run `selftune registry install <name>`. Use `--global` if they want it
86
- > available across all projects.
89
+ > Run `selftune registry install <name>` for a cloud-published skill, or
90
+ > `selftune registry install github:owner/repo[@ref][//path]` if they want to
91
+ > install directly from GitHub. Use `--global` if they want it available across
92
+ > all projects.
87
93
 
88
94
  **User wants to check what's outdated**
89
95