Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions design/repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 44 additions & 2 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -358,6 +361,7 @@ export async function configureExtensionLoaders(
);

await checkForMissingPulledExtensions(repoDir, marker, deferredWarnings);
await checkForSupersededSkills(repoDir, marker, deferredWarnings);
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -843,6 +854,37 @@ async function checkForMissingPulledExtensions(
}
}

async function checkForSupersededSkills(
repoDir: string,
marker: RepoMarkerData | null,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const tools = marker?.tools?.length
? marker.tools
: [resolvePrimaryTool(marker)];
const allStale = new Set<string>();
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";

Expand Down Expand Up @@ -1111,7 +1153,7 @@ export async function runCli(args: string[]): Promise<void> {

// 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
Expand Down
17 changes: 16 additions & 1 deletion src/domain/repo/repo_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -125,6 +125,21 @@ async function removeSupersededSkills(skillsDir: string): Promise<void> {
}
}

export async function detectSupersededSkills(
skillsDir: string,
): Promise<string[]> {
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
Expand Down
26 changes: 25 additions & 1 deletion src/domain/repo/repo_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, []);
});
Loading