Add a feature catalog build artifact derived from the experimental settings schema (#10055)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Instances expose ~23 experimental feature settings, all declared in
one shared zod schema and toggled per instance
> - Deployment tooling and hosting control planes have no
machine-readable list of those feature keys for a given release — the
schema is only reachable from code that imports the package
> - Any external system that references feature keys therefore does so
as free text, and typos drift silently
> - This pull request derives a versioned `feature-catalog.json` build
artifact from the schema, with a compiler-checked metadata map so the
schema stays the single source of truth
> - The benefit is a stable contract external tooling can validate
feature-key references against, with zero runtime behavior change

## Linked Issues or Issue Description

No public issue exists; `feature_request` template fields:

**Problem or motivation:**
External deployment tooling cannot enumerate or validate an instance's
feature keys per release; free-text references fail silently when keys
are renamed or removed.

**Proposed solution:**
A metadata map keyed by the settings schema's own keys (compiler flags
drift) plus a build step emitting `feature-catalog.json` (keys, tiers,
defaults, `catalogVersion`) as a release artifact.

**Alternatives considered:**
A hand-maintained catalog file (drifts from the schema); serving the
schema from a runtime API (requires a running instance at validation
time — a build artifact works offline and pins to a release).

**Roadmap alignment:**
Supports the in-progress "Cloud deployments" milestone in `ROADMAP.md`.

## What Changed

Adds a metadata map (title, description, tier, cloud/self-hosted
defaults) keyed by the keys of `instanceExperimentalSettingsSchema`, so
the schema stays the single source of truth and the compiler flags any
drift. A new build step (`build:feature-catalog --version <v>`) emits
`feature-catalog.json` — all 23 feature keys, their tiers, and a
`catalogVersion` — as a release artifact that managed-hosting control
planes can validate feature-flag writes against. No runtime behavior
changes.

- New `packages/shared/src/feature-catalog.ts`: per-flag metadata map
keyed by a type derived from the settings schema
(adding/removing/renaming a flag without updating the map is a compile
error), plus `featureCatalogArtifactSchema` and
`buildFeatureCatalogArtifact`/`renderFeatureCatalogArtifact` for the
artifact
- New `scripts/generate-feature-catalog.ts` wired as `pnpm
build:feature-catalog --version <v>`
- `scripts/create-github-release.sh` generates the artifact and uploads
it as a GitHub Release asset (with a dry-run preview line)
- Tests in `packages/shared/src/feature-catalog.test.ts`

## Verification

- `vitest run packages/shared/src/feature-catalog.test.ts` — 9 tests:
schema-key coverage, drift detection, artifact shape
- `pnpm --filter @paperclipai/shared typecheck`
- Artifact generation run end-to-end: `pnpm build:feature-catalog
--version 0.0.0-test` emits 23 keys with `catalogVersion`

## Risks

Low risk — no runtime behavior changes; the change is metadata, a build
script, and a release-artifact emission step only.

## Model Used

Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use;
independently peer-reviewed by a second AI agent before push

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-22 18:12:56 -07:00 committed by GitHub
parent e55d702916
commit ad74fb5450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 467 additions and 0 deletions

View File

@ -15,6 +15,7 @@
"storybook": "pnpm --filter @paperclipai/ui storybook",
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
"build:feature-catalog": "node cli/node_modules/tsx/dist/cli.mjs scripts/generate-feature-catalog.ts",
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs",
"test": "pnpm run test:run",

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
FEATURE_TIERS,
INSTANCE_FEATURE_CATALOG,
INSTANCE_FEATURE_KEYS,
buildFeatureCatalogArtifact,
featureCatalogArtifactSchema,
renderFeatureCatalogArtifact,
} from "./feature-catalog.js";
import { instanceExperimentalSettingsSchema } from "./validators/instance.js";
function schemaBooleanFlagKeys(): string[] {
return Object.entries(instanceExperimentalSettingsSchema.shape)
.filter(([, fieldSchema]) => {
let current: z.ZodTypeAny = fieldSchema as z.ZodTypeAny;
for (;;) {
if (current instanceof z.ZodDefault) {
current = current._def.innerType as z.ZodTypeAny;
} else if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
current = current.unwrap() as z.ZodTypeAny;
} else {
break;
}
}
return current instanceof z.ZodBoolean;
})
.map(([key]) => key)
.sort();
}
describe("INSTANCE_FEATURE_CATALOG", () => {
it("covers exactly the boolean flag keys of the experimental settings schema", () => {
expect([...INSTANCE_FEATURE_KEYS]).toEqual(schemaBooleanFlagKeys());
});
it("keeps selfHostedDefault in sync with the schema defaults", () => {
const schemaDefaults = instanceExperimentalSettingsSchema.parse({});
for (const key of INSTANCE_FEATURE_KEYS) {
expect(INSTANCE_FEATURE_CATALOG[key].selfHostedDefault, key).toBe(schemaDefaults[key]);
}
});
it("has a non-empty title, description, and valid tier for every flag", () => {
for (const key of INSTANCE_FEATURE_KEYS) {
const entry = INSTANCE_FEATURE_CATALOG[key];
expect(entry.title.trim().length, key).toBeGreaterThan(0);
expect(entry.description.trim().length, key).toBeGreaterThan(0);
expect(FEATURE_TIERS, key).toContain(entry.tier);
}
});
});
describe("buildFeatureCatalogArtifact", () => {
it("emits catalogVersion plus one tier entry per flag key", () => {
const artifact = buildFeatureCatalogArtifact("2026.720.0");
expect(artifact.catalogVersion).toBe("2026.720.0");
expect(Object.keys(artifact.features)).toEqual([...INSTANCE_FEATURE_KEYS]);
for (const key of INSTANCE_FEATURE_KEYS) {
expect(artifact.features[key]).toEqual({ tier: INSTANCE_FEATURE_CATALOG[key].tier });
}
});
it("produces output that validates against featureCatalogArtifactSchema", () => {
const artifact = buildFeatureCatalogArtifact("2026.720.0");
expect(featureCatalogArtifactSchema.parse(artifact)).toEqual(artifact);
});
it("rejects an empty catalogVersion", () => {
expect(() => buildFeatureCatalogArtifact("")).toThrow(/catalogVersion/);
expect(() => buildFeatureCatalogArtifact(" ")).toThrow(/catalogVersion/);
});
});
describe("featureCatalogArtifactSchema", () => {
it("rejects unknown top-level or per-feature properties", () => {
const valid = buildFeatureCatalogArtifact("2026.720.0");
expect(
featureCatalogArtifactSchema.safeParse({ ...valid, extra: true }).success,
).toBe(false);
expect(
featureCatalogArtifactSchema.safeParse({
catalogVersion: "2026.720.0",
features: { enableApps: { tier: "managed", extra: true } },
}).success,
).toBe(false);
});
it("rejects unknown tiers and a missing catalogVersion", () => {
expect(
featureCatalogArtifactSchema.safeParse({
catalogVersion: "2026.720.0",
features: { enableApps: { tier: "mystery" } },
}).success,
).toBe(false);
expect(
featureCatalogArtifactSchema.safeParse({ features: {} }).success,
).toBe(false);
});
});
describe("renderFeatureCatalogArtifact", () => {
it("is deterministic, sorted, and newline-terminated", () => {
const rendered = renderFeatureCatalogArtifact("2026.720.0");
expect(rendered).toBe(renderFeatureCatalogArtifact("2026.720.0"));
expect(rendered.endsWith("\n")).toBe(true);
const parsed = featureCatalogArtifactSchema.parse(JSON.parse(rendered));
expect(Object.keys(parsed.features)).toEqual([...Object.keys(parsed.features)].sort());
expect(parsed).toEqual(buildFeatureCatalogArtifact("2026.720.0"));
});
});

View File

@ -0,0 +1,259 @@
import { z } from "zod";
import { instanceExperimentalSettingsSchema } from "./validators/instance.js";
/**
* Feature catalog for cloud-managed instances.
*
* The instance-settings zod schema is the feature manifest; this module adds
* only metadata about the flags the schema already declares. Keys are derived
* from the schema type, so adding, removing, or renaming a boolean flag in
* `instanceExperimentalSettingsSchema` without updating the metadata map is a
* compile error (and vice versa).
*
* Tiers:
* - `preference`: tenant-controllable taste setting; the cloud harness does
* not manage it.
* - `managed`: the cloud harness may set this per fleet/stack via
* `PAPERCLIP_MANAGED_CONFIG`.
* - `floor`: pinned by code on managed instances; no flag value may widen it.
*/
export const FEATURE_TIERS = ["preference", "managed", "floor"] as const;
export type FeatureTier = (typeof FEATURE_TIERS)[number];
type ExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
/**
* The boolean flag keys of the experimental settings schema. Non-flag keys
* (activation timestamps, numeric tuning values) are excluded.
*/
export type InstanceFeatureKey = {
[K in keyof ExperimentalSettings]: ExperimentalSettings[K] extends boolean ? K : never;
}[keyof ExperimentalSettings];
export interface FeatureCatalogEntry {
title: string;
description: string;
tier: FeatureTier;
/** Desired default on cloud-managed instances. */
cloudDefault: boolean;
/** Must match the schema default; enforced by test. */
selfHostedDefault: boolean;
}
export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalogEntry> = {
enableEnvironments: {
title: "Environments",
description:
"Show environment management in company settings and allow project and agent environment assignment controls.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableIsolatedWorkspaces: {
title: "Isolated Workspaces",
description:
"Show execution workspace controls in project configuration and allow isolated workspace behavior for task runs.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableStreamlinedLeftNavigation: {
title: "Streamlined Left Navigation",
description: "Use the streamlined main sidebar navigation layout.",
tier: "preference",
cloudDefault: true,
selfHostedDefault: true,
},
enableApps: {
title: "Apps",
description:
"Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enablePipelines: {
title: "Pipelines",
description: "Enable pipeline definitions and pipeline-driven case production surfaces.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableCases: {
title: "Cases",
description:
"Durable work products that tasks create and iterate on. Adds the Cases tab and the agent case API.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableConferenceRoomChat: {
title: "Conference Room Chat",
description:
"Add the Conference Room team chat, the live activity feed, and the redesigned onboarding; restyles task threads as chat bubbles.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableTaskWatchdogs: {
title: "Task Watchdogs",
description:
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableIssuePlanDecompositions: {
title: "Task Plan Decomposition Panel",
description: "Show accepted-plan decomposition history on task detail pages.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableExperimentalFileViewer: {
title: "Experimental File Viewer",
description:
"Show task detail controls for browsing and previewing workspace files relative to a task.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableCloudSync: {
title: "Cloud Sync",
description:
"Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableExternalObjects: {
title: "External Objects",
description:
"Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced work objects.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableSmokeLab: {
title: "Smoke Lab",
description:
"Add the Smoke Lab tab and dashboard card for exercising integration paths against deterministic local fixtures. Private deployments only.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableBuiltInAgents: {
title: "Built-in Agents",
description:
"Show Paperclip-managed built-in agent surfaces, including roster badges, the Built-in agents tab, and setup controls.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableSummaries: {
title: "Summaries",
description:
"Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableDecisions: {
title: "Decisions",
description:
"Show the Decisions item in the main sidebar — the attention home that surfaces tasks awaiting input.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableGoalsSidebarLink: {
title: "Goals Sidebar Link",
description: "Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableServerInfoDebugView: {
title: "Server Info Debug View",
description:
"Show a Server section in the account drawer with the current server restart time and running commit.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
autoRestartDevServerWhenIdle: {
title: "Auto-Restart Dev Server When Idle",
description:
"In local development, wait for queued and running agent runs to finish, then restart the server automatically when backend changes make the current boot stale.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableIssueGraphLivenessAutoRecovery: {
title: "Auto-Create Recovery Tasks",
description:
"Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the configured lookback window.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableWorkspaceBranchReconcileForward: {
title: "Workspace Branch Reconcile Forward",
description:
"Let execution workspaces reconcile a diverged recorded branch forward instead of failing branch containment.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: true,
},
enableWorkspaceDirtyQuarantineRepair: {
title: "Workspace Dirty Quarantine Repair",
description:
"Let workspace runtime recovery quarantine and repair dirty execution workspaces before runs.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: true,
},
enableWorktreeRunExecution: {
title: "Worktree Run Execution",
description:
"Let the scheduler execute runs inside an isolated git-worktree preview instance for tasks created after activation.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
};
export const INSTANCE_FEATURE_KEYS = Object.keys(INSTANCE_FEATURE_CATALOG).sort() as InstanceFeatureKey[];
/**
* Shape of the `feature-catalog.json` release artifact the cloud harness
* imports per app release and validates feature writes against.
*/
export const featureCatalogArtifactSchema = z
.object({
catalogVersion: z.string().min(1),
features: z.record(
z.string().min(1),
z.object({ tier: z.enum(FEATURE_TIERS) }).strict(),
),
})
.strict();
export type FeatureCatalogArtifact = z.infer<typeof featureCatalogArtifactSchema>;
export function buildFeatureCatalogArtifact(catalogVersion: string): FeatureCatalogArtifact {
if (catalogVersion.trim().length === 0) {
throw new Error("catalogVersion must be a non-empty string");
}
const features: FeatureCatalogArtifact["features"] = {};
for (const key of INSTANCE_FEATURE_KEYS) {
features[key] = { tier: INSTANCE_FEATURE_CATALOG[key].tier };
}
return { catalogVersion, features };
}
/** Deterministic serialization (sorted keys, trailing newline) for the artifact file. */
export function renderFeatureCatalogArtifact(catalogVersion: string): string {
return `${JSON.stringify(buildFeatureCatalogArtifact(catalogVersion), null, 2)}\n`;
}

View File

@ -2219,3 +2219,15 @@ export {
type EnvironmentCustomImageTerminalSessionToken,
} from "./validators/environment-custom-images.js";
export * from "./validators/skill-policy.js";
export {
FEATURE_TIERS,
INSTANCE_FEATURE_CATALOG,
INSTANCE_FEATURE_KEYS,
buildFeatureCatalogArtifact,
featureCatalogArtifactSchema,
renderFeatureCatalogArtifact,
type FeatureCatalogArtifact,
type FeatureCatalogEntry,
type FeatureTier,
type InstanceFeatureKey,
} from "./feature-catalog.js";

View File

@ -80,8 +80,30 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
exit 1
fi
# The catalog is derived from the checked-out sources, so it must be generated
# from the exact commit the release tag points at, with no local edits.
tag_commit="$(git -C "$REPO_ROOT" rev-parse "$tag^{commit}")"
head_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)"
if [ "$head_commit" != "$tag_commit" ]; then
echo "Error: HEAD ($head_commit) does not match tag $tag ($tag_commit). Check out the release tag before generating the feature catalog." >&2
exit 1
fi
if [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no)" ]; then
echo "Error: working tree has uncommitted changes. The feature catalog must be generated from the pristine release commit." >&2
exit 1
fi
catalog_dir="$(mktemp -d)"
trap 'rm -rf "$catalog_dir"' EXIT
catalog_file="$catalog_dir/feature-catalog.json"
node "$REPO_ROOT/cli/node_modules/tsx/dist/cli.mjs" \
"$REPO_ROOT/scripts/generate-feature-catalog.ts" \
--version "$version" \
--out "$catalog_file"
if [ "$dry_run" = true ]; then
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
echo "[dry-run] gh release upload $tag -R $GITHUB_REPO --clobber $catalog_file"
exit 0
fi
@ -97,3 +119,6 @@ else
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Created GitHub Release $tag"
fi
gh release upload "$tag" -R "$GITHUB_REPO" --clobber "$catalog_file"
echo "Uploaded feature-catalog.json to GitHub Release $tag"

View File

@ -0,0 +1,59 @@
// Emits the feature-catalog.json release artifact the cloud harness imports
// per app release and validates feature writes against. The catalog content
// is derived from the instance-settings schema metadata in
// packages/shared/src/feature-catalog.ts.
//
// Usage:
// tsx scripts/generate-feature-catalog.ts --version 2026.720.0 [--out path/to/feature-catalog.json]
//
// Without --out, the artifact is written to stdout.
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import process from "node:process";
import { renderFeatureCatalogArtifact } from "../packages/shared/src/feature-catalog.js";
function usage(): never {
console.error(
"Usage: tsx scripts/generate-feature-catalog.ts --version <catalogVersion> [--out <file>]",
);
process.exit(1);
}
let version = "";
let outPath = "";
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
switch (args[i]) {
case "--version":
version = args[++i] ?? "";
break;
case "--out":
outPath = args[++i] ?? "";
break;
case "-h":
case "--help":
usage();
break;
default:
console.error(`Unknown argument: ${args[i]}`);
usage();
}
}
if (version.trim().length === 0) {
console.error("Error: --version is required and must be non-empty.");
usage();
}
const rendered = renderFeatureCatalogArtifact(version);
if (outPath) {
const target = resolve(outPath);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, rendered, "utf8");
console.error(`Wrote feature catalog for version ${version} to ${target}`);
} else {
process.stdout.write(rendered);
}