diff --git a/design/repo.md b/design/repo.md index f0087d8b..e09fe9e5 100644 --- a/design/repo.md +++ b/design/repo.md @@ -51,6 +51,24 @@ The compiled swamp binary should include everything it needs to initialize a repository, including the skill files, so that they can be written out by the cli. +## Superseded Skill Detection + +When the CLI binary is upgraded but `swamp repo upgrade` is not run, the +repo retains old skill directories that have been consolidated in the new +version. The `SUPERSEDED_SKILLS` constant in `repo_service.ts` lists these +directory names. + +On every CLI startup (for repo-scoped commands), the CLI checks all enrolled +tools' skill directories for superseded subdirectories. If any are found, a +warning is emitted via the deferred-warning system: + +``` +WRN 2 superseded skill(s) found: swamp-data-query, swamp-extension-model. Run 'swamp repo upgrade' to update skills. +``` + +This check is non-fatal — it never blocks startup. `swamp repo upgrade` +removes the superseded directories via `removeSupersededSkills()`. + ## Repository Layout Source-of-truth files live in top-level directories tracked in git: diff --git a/src/cli/mod.ts b/src/cli/mod.ts index fb2ee3f7..f8df615e 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -87,6 +87,9 @@ import { RepoMarkerRepository, } from "../infrastructure/persistence/repo_marker_repository.ts"; import { RepoPath } from "../domain/repo/repo_path.ts"; +import { detectSupersededSkills } from "../domain/repo/repo_service.ts"; +import { resolvePrimaryTool } from "../domain/repo/primary_tool.ts"; +import { SKILL_DIRS } from "../domain/repo/skill_dirs.ts"; import { ExtensionAutoResolver } from "../domain/extensions/extension_auto_resolver.ts"; import { ExtensionApiClient } from "../infrastructure/http/extension_api_client.ts"; import { DEFAULT_SWAMP_CLUB_URL } from "../domain/auth/auth_credentials.ts"; @@ -358,6 +361,7 @@ export async function configureExtensionLoaders( ); await checkForMissingPulledExtensions(repoDir, marker, deferredWarnings); + await checkForSupersededSkills(repoDir, marker, deferredWarnings); } /** @@ -424,7 +428,14 @@ export function commandNeedsLoaderSetup(args: string[]): boolean { /** A deferred warning message to emit after logging is initialized. */ export interface DeferredWarning { - kind: "model" | "vault" | "driver" | "datastore" | "report" | "extensions"; + kind: + | "model" + | "vault" + | "driver" + | "datastore" + | "report" + | "extensions" + | "skills"; file: string; error: string; } @@ -843,6 +854,37 @@ async function checkForMissingPulledExtensions( } } +async function checkForSupersededSkills( + repoDir: string, + marker: RepoMarkerData | null, + deferredWarnings: DeferredWarning[], +): Promise { + try { + const tools = marker?.tools?.length + ? marker.tools + : [resolvePrimaryTool(marker)]; + const allStale = new Set(); + for (const tool of tools) { + const dir = SKILL_DIRS[tool]; + if (!dir) continue; + const stale = await detectSupersededSkills(join(repoDir, dir)); + for (const name of stale) allStale.add(name); + } + if (allStale.size > 0) { + const names = [...allStale].sort(); + deferredWarnings.push({ + kind: "skills", + file: repoDir, + error: `${names.length} superseded skill(s) found: ${ + names.join(", ") + }. Run 'swamp repo upgrade' to update skills.`, + }); + } + } catch { + // Non-fatal — don't block startup for skill dir read errors + } +} + /** Default telemetry endpoint */ const DEFAULT_TELEMETRY_ENDPOINT = "https://telemetry.swamp-club.com"; @@ -1111,7 +1153,7 @@ export async function runCli(args: string[]): Promise { // Emit deferred warnings now that logging is initialized for (const warning of deferredWarnings) { - if (warning.kind === "extensions") { + if (warning.kind === "extensions" || warning.kind === "skills") { logger.warn`${warning.error}`; } else { logger diff --git a/src/domain/repo/repo_service.ts b/src/domain/repo/repo_service.ts index 290c1398..df74be0f 100644 --- a/src/domain/repo/repo_service.ts +++ b/src/domain/repo/repo_service.ts @@ -104,7 +104,7 @@ function formatToolsList(tools: AiTool[]): string { return tools.length === 0 ? "none" : tools.join(", "); } -const SUPERSEDED_SKILLS = [ +export const SUPERSEDED_SKILLS: readonly string[] = [ "swamp-extension-model", "swamp-extension-vault", "swamp-extension-driver", @@ -125,6 +125,21 @@ async function removeSupersededSkills(skillsDir: string): Promise { } } +export async function detectSupersededSkills( + skillsDir: string, +): Promise { + const found: string[] = []; + for (const name of SUPERSEDED_SKILLS) { + try { + await Deno.stat(join(skillsDir, name)); + found.push(name); + } catch { + // Not found — not stale + } + } + return found; +} + /** * Pulled extensions present for one of the previously-enrolled tools that * were NOT copied into a newly-added tool's skills directory. Surfaced so diff --git a/src/domain/repo/repo_service_test.ts b/src/domain/repo/repo_service_test.ts index 64ec76eb..9e5ce9e0 100644 --- a/src/domain/repo/repo_service_test.ts +++ b/src/domain/repo/repo_service_test.ts @@ -19,7 +19,7 @@ import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { join } from "@std/path"; -import { RepoService } from "./repo_service.ts"; +import { detectSupersededSkills, RepoService } from "./repo_service.ts"; import { RepoPath } from "./repo_path.ts"; import { type AiTool, @@ -2484,3 +2484,27 @@ Deno.test("RepoService.upgrade adding a tool with no pulled extensions emits no assertEquals(result.extensionsToReinstall, []); }); }); + +Deno.test("detectSupersededSkills: returns empty when no superseded dirs exist", async () => { + await withTempDir(async (tempDir) => { + const result = await detectSupersededSkills(tempDir); + assertEquals(result, []); + }); +}); + +Deno.test("detectSupersededSkills: detects superseded skill directories", async () => { + await withTempDir(async (tempDir) => { + await Deno.mkdir(join(tempDir, "swamp-extension-model")); + await Deno.mkdir(join(tempDir, "swamp-data-query")); + // Non-superseded dir should be ignored + await Deno.mkdir(join(tempDir, "swamp-model")); + + const result = await detectSupersededSkills(tempDir); + assertEquals(result.sort(), ["swamp-data-query", "swamp-extension-model"]); + }); +}); + +Deno.test("detectSupersededSkills: returns empty for nonexistent directory", async () => { + const result = await detectSupersededSkills("/tmp/nonexistent-dir-326"); + assertEquals(result, []); +});