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
7 changes: 7 additions & 0 deletions .changeset/calm-caplet-readme-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@caplets/core": minor
---

Make Caplet YAML frontmatter the sole runtime configuration contract while preserving the Markdown body as operator-facing README content for catalog rendering. Remove `body` from the exported runtime configuration types and backend projections, add semantic runtime fingerprints and no-op reload gating, and classify trusted README-only install changes as `content_updated` without setup approval or runtime churn.

**Migration required:** `useWhen` and `avoidWhen` have been removed from Caplet and configured-action schemas. Move concise agent-facing capability context into `description`; move operator-only prerequisites, safety guidance, troubleshooting, and references into the Markdown body. Existing configuration that still declares either removed field is rejected.
6 changes: 6 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Shared domain vocabulary for this project -- entities, named processes, and stat

A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations.

### Caplet File

A portable Markdown artifact whose YAML frontmatter is the sole source of runtime-affecting Caplet configuration and whose body is an operator README for prerequisites, troubleshooting, safety, and Caplet-specific documentation.

The frontmatter and body are independent projections with separate lifecycles: the body may be shared and rendered for humans but never enters runtime configuration or an agent capability surface. Other files in the Caplet bundle become runtime inputs only through explicit frontmatter references.

### Caplets Exposure Projection

The shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets.
Expand Down
118 changes: 39 additions & 79 deletions apps/catalog/src/data/official-catalog.json

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions apps/catalog/src/lib/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,6 @@ async function canonicalEntryForAcceptedSignal(
resolvedRevision: signal.resolvedRevision,
}),
tags: catalogStringArrayFromFrontmatter(frontmatter.tags),
useWhen: catalogStringFromFrontmatter(frontmatter.useWhen),
avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen),
setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter),
authRequired: catalogAuthRequiredFromFrontmatter(frontmatter),
projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter),
Expand Down
3 changes: 0 additions & 3 deletions apps/catalog/test/ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ describe("catalog install signal ingestion", () => {
indexedContentHash: "sha256:abc",
contentMarkdown: "# Deploy",
tags: ["deploy"],
intendedTask: "Deploy projects.",
setupReadiness: "ready",
authReadiness: "ready",
projectBindingReadiness: "ready",
Expand Down Expand Up @@ -256,7 +255,6 @@ describe("catalog install signal ingestion", () => {
indexedContentHash: "wrong",
contentMarkdown: "# Deploy",
tags: ["deploy"],
intendedTask: "Deploy projects.",
setupReadiness: "ready",
authReadiness: "ready",
projectBindingReadiness: "ready",
Expand Down Expand Up @@ -454,7 +452,6 @@ function submittedEntry(): CatalogEntry {
trustLevel: "community",
contentMarkdown: "# Deploy",
tags: ["deploy"],
intendedTask: "Deploy projects.",
setupReadiness: "ready",
authReadiness: "ready",
projectBindingReadiness: "ready",
Expand Down
14 changes: 13 additions & 1 deletion apps/dashboard/src/components/DashboardApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { dashboardApi, setDashboardSession, toast } = vi.hoisted(() => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}));

Expand All @@ -22,7 +23,7 @@ vi.mock("@/lib/api", () => ({
vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null }));
vi.mock("sonner", () => ({ toast }));

import { DashboardApp } from "./DashboardApp";
import { DashboardApp, catalogMutationLabel } from "./DashboardApp";

type Deferred<T> = {
promise: Promise<T>;
Expand Down Expand Up @@ -143,6 +144,7 @@ beforeEach(() => {
setDashboardSession.mockClear();
toast.error.mockClear();
toast.success.mockClear();
toast.warning.mockClear();
window.history.replaceState({}, "", "/dashboard/vault");
window.matchMedia = vi.fn().mockReturnValue({
addEventListener: vi.fn(),
Expand All @@ -164,6 +166,16 @@ afterEach(async () => {
vi.restoreAllMocks();
});

describe("catalog update presentation", () => {
it("distinguishes committed content, runtime, and no-op outcomes", () => {
expect(catalogMutationLabel({ installed: [{ status: "content_updated" }] })).toBe(
"Content updated",
);
expect(catalogMutationLabel({ installed: [{ status: "updated" }] })).toBe("Updated");
expect(catalogMutationLabel({ installed: [{ status: "noop" }] })).toBe("Already current");
});
});

describe("Vault reveal races", () => {
it("dismisses a parent-owned reveal confirmation when Vault unmounts", async () => {
await mountVault();
Expand Down
48 changes: 41 additions & 7 deletions apps/dashboard/src/components/DashboardApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ import { CatalogPage } from "@/components/catalog/CatalogPage";
const REVEAL_DURATION_SECONDS = EPHEMERAL_REVEAL_TTL_MS / 1_000;
const ACTION_DISCARDED = Symbol("dashboard-action-discarded");

type CatalogMutationStatus = "installed" | "restored" | "updated" | "content_updated" | "noop";

type CatalogMutationResult = {
installed?: Array<{
status?: CatalogMutationStatus;
catalogIndexing?: { status?: string; reason?: string };
}>;
};
type DashboardAction = (
label: string | ((result: unknown) => string),
callback: () => Promise<unknown>,
) => Promise<void>;
export function catalogMutationLabel(result: unknown): string {
const status = (result as CatalogMutationResult | undefined)?.installed?.[0]?.status;
if (status === "content_updated") return "Content updated";
if (status === "noop") return "Already current";
if (status === "restored") return "Restored";
if (status === "installed") return "Installed";
return "Updated";
}

function catalogIndexingUnavailable(result: unknown): boolean {
return Boolean(
(result as CatalogMutationResult | undefined)?.installed?.some(
(entry) => entry.catalogIndexing?.status === "unavailable",
),
);
}

type RouteKey =
| "overview"
| "access"
Expand Down Expand Up @@ -426,20 +455,25 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou
}
}

async function action(label: string, callback: () => Promise<unknown>) {
const action: DashboardAction = async (label, callback) => {
try {
const result = await callback();
if (result === ACTION_DISCARDED) return;
const refreshed = await refresh();
if (refreshed) toast.success(label);
if (refreshed) {
toast.success(typeof label === "function" ? label(result) : label);
if (catalogIndexingUnavailable(result)) {
toast.warning("Catalog indexing unavailable; the committed update is still installed.");
}
}
} catch (error) {
if (isDashboardUnauthorized(error)) {
endDashboardSession();
return;
}
toast.error(error instanceof Error ? error.message : String(error));
}
}
};

async function logout() {
try {
Expand Down Expand Up @@ -747,7 +781,7 @@ function Page({
data: DashboardData;
loading: boolean;
session: DashboardSession;
action: (label: string, callback: () => Promise<unknown>) => Promise<void>;
action: DashboardAction;
}) {
const { confirmTyped } = useActionConfirm();
if (route === "access") return <AccessPage data={data} loading={loading} action={action} />;
Expand Down Expand Up @@ -1362,7 +1396,7 @@ function CapletsPage({
}: {
data: DashboardData;
loading: boolean;
action: (label: string, callback: () => Promise<unknown>) => Promise<void>;
action: DashboardAction;
}) {
const { confirmTyped } = useActionConfirm();
const caplets = data.caplets?.caplets ?? [];
Expand Down Expand Up @@ -1399,8 +1433,8 @@ function CapletsPage({
))
)
return;
await action("Update requested", () =>
dashboardApi("catalog/update", {
await action(catalogMutationLabel, () =>
dashboardApi<CatalogMutationResult>("catalog/update", {
method: "POST",
body: JSON.stringify({
capletId,
Expand Down
Loading