fix(skills): pull upstream skill runtime resolution to stop event-loop starvation
Build: Production / build (push) Failing after 12m39s

The fork's listRuntimeSkillEntries rematerialized every skill's files from
the DB on every heartbeat run dispatch — fs.rm + fs.mkdir + per-file
readFile/writeFile, sequentially per skill. With 24 configured skills and
5 concurrent agents, this saturated the Node event loop badly enough that
executeRun continuations couldn't reach activeRunExecutions.add() within
the orphan-reaper's 5-min threshold, causing reaper to false-positive runs
as "process_lost".

Upstream's listRuntimeSkillEntries calls resolveRuntimeSkillSource, which
checks if the materialized directory already exists on disk and short-
circuits when it does. Fixes the symptom at the root.

Replaces these files with upstream/master content:
  - server/src/services/company-skills.ts
  - server/src/services/heartbeat.ts
  - server/src/services/workspace-runtime.ts
  - server/src/services/company-portability.ts
  - server/src/routes/company-skills.ts
  - server/src/routes/agents.ts
  - packages/adapter-utils/src/server-utils.ts

Pulls in supporting upstream files:
  - server/src/services/catalog-provenance.ts
  - server/src/services/skills-catalog.ts
  - server/src/services/github-fetch.ts
  - server/src/services/portable-path.ts
  - packages/skills-catalog/ (new package)
  - packages/db document_annotation_* schema + migration 0091
  - packages/shared document-annotation types/validators

Drops fork features (to be re-evaluated later):
  - Gitea/Forgejo git skill sources (server/src/services/git-source.ts deleted)
  - PAT support for private skill repos
  - Fork-specific secret-export portability extensions

Adds agentId: null to acquireRunLease test-probe call in routes/agents.ts
to satisfy the fork's environment-runtime agentId requirement (kept).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 09:26:51 -04:00
parent 562693197a
commit 548d958f18
52 changed files with 24613 additions and 2036 deletions
+177
View File
@@ -133,6 +133,8 @@ export interface PaperclipSkillEntry {
key: string;
runtimeName: string;
source: string;
sourceStatus?: "available" | "missing";
missingDetail?: string | null;
required?: boolean;
requiredReason?: string | null;
}
@@ -161,6 +163,22 @@ interface PersistentSkillSnapshotOptions {
warnings?: string[];
}
interface RuntimeMountedSkillSnapshotOptions {
adapterType: string;
availableEntries: PaperclipSkillEntry[];
desiredSkills: string[];
configuredDetail: string | ((entry: PaperclipSkillEntry) => string | null);
missingDetail?: string;
mode?: "ephemeral" | "unsupported";
supported?: boolean;
unsupportedDetail?: string | ((entry: PaperclipSkillEntry) => string | null);
warnings?: string[];
externalInstalled?: Map<string, InstalledSkillTarget>;
externalLocationLabel?: string | null;
externalDetail?: string;
skillsHome?: string;
}
function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/");
}
@@ -193,6 +211,26 @@ function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
};
}
function isPaperclipSkillSourceMissing(entry: PaperclipSkillEntry) {
return entry.sourceStatus === "missing";
}
function resolvePaperclipSkillMissingDetail(
entry: PaperclipSkillEntry,
fallback: string,
) {
return entry.missingDetail?.trim() || fallback;
}
function resolveSkillDetail(
detail: string | ((entry: PaperclipSkillEntry) => string | null) | null | undefined,
entry: PaperclipSkillEntry,
): string | null {
if (typeof detail === "function") return detail(entry);
if (typeof detail === "string") return detail;
return null;
}
function resolveInstalledEntryTarget(
skillsHome: string,
entryName: string,
@@ -1381,6 +1419,120 @@ export async function readInstalledSkillTargets(skillsHome: string): Promise<Map
return out;
}
export function buildRuntimeMountedSkillSnapshot(
options: RuntimeMountedSkillSnapshotOptions,
): AdapterSkillSnapshot {
const {
adapterType,
availableEntries,
desiredSkills,
configuredDetail,
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
mode = "ephemeral",
externalInstalled,
externalLocationLabel,
externalDetail = "Installed outside Paperclip management.",
skillsHome,
} = options;
const supported = options.supported ?? mode !== "unsupported";
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = [];
const warnings = [...(options.warnings ?? [])];
for (const available of availableEntries) {
const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
const configured = supported && mode === "ephemeral" && desired;
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: configured ? "configured" : "available",
sourcePath: available.source,
targetPath: null,
detail: desired
? configured
? resolveSkillDetail(configuredDetail, available)
: resolveSkillDetail(
options.unsupportedDetail
?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
available,
)
: null,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
}
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: missingDetail,
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
});
}
if (externalInstalled) {
for (const [name, installedEntry] of externalInstalled.entries()) {
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: skillLocationLabel(externalLocationLabel),
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? (skillsHome ? path.join(skillsHome, name) : null),
detail: externalDetail,
});
}
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType,
supported,
mode,
desiredSkills,
entries,
warnings,
};
}
export function buildPersistentSkillSnapshot(
options: PersistentSkillSnapshotOptions,
): AdapterSkillSnapshot {
@@ -1404,6 +1556,26 @@ export function buildPersistentSkillSnapshot(
for (const available of availableEntries) {
const installedEntry = installed.get(available.runtimeName) ?? null;
const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, available.runtimeName),
detail: resolvePaperclipSkillMissingDetail(
available,
missingDetail,
),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
let state: AdapterSkillEntry["state"] = "available";
let managed = false;
let detail: string | null = null;
@@ -1496,6 +1668,11 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
key,
runtimeName,
source,
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
missingDetail:
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
? entry.missingDetail.trim()
: null,
required: asBoolean(entry.required, false),
requiredReason:
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
@@ -0,0 +1,189 @@
CREATE TABLE IF NOT EXISTS "document_annotation_threads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"document_key" text NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"anchor_state" text DEFAULT 'active' NOT NULL,
"original_revision_id" uuid,
"original_revision_number" integer NOT NULL,
"current_revision_id" uuid,
"current_revision_number" integer NOT NULL,
"selected_text" text NOT NULL,
"prefix_text" text DEFAULT '' NOT NULL,
"suffix_text" text DEFAULT '' NOT NULL,
"normalized_start" integer NOT NULL,
"normalized_end" integer NOT NULL,
"markdown_start" integer NOT NULL,
"markdown_end" integer NOT NULL,
"anchor_confidence" text DEFAULT 'exact' NOT NULL,
"anchor_selector" jsonb NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"resolved_by_agent_id" uuid,
"resolved_by_user_id" text,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_annotation_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"thread_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"body" text NOT NULL,
"author_type" text NOT NULL,
"author_agent_id" uuid,
"author_user_id" text,
"created_by_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_annotation_anchor_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"thread_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"from_revision_id" uuid,
"from_revision_number" integer,
"to_revision_id" uuid,
"to_revision_number" integer NOT NULL,
"previous_anchor" jsonb NOT NULL,
"next_anchor" jsonb,
"anchor_state" text NOT NULL,
"anchor_confidence" text NOT NULL,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_issue_id_issues_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_original_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_current_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_created_by_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_resolved_by_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_thread_id_document_annotation_threads_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_issue_id_issues_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_author_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_document_status_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_issue_status_idx" ON "document_annotation_threads" USING btree ("company_id","issue_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_current_revision_open_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","current_revision_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_anchor_state_idx" ON "document_annotation_threads" USING btree ("company_id","anchor_state");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_thread_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","thread_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_issue_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","issue_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_document_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","document_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_body_search_idx" ON "document_annotation_comments" USING gin ("body" gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_thread_created_at_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","thread_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_document_revision_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number");
File diff suppressed because it is too large Load Diff
@@ -638,6 +638,13 @@
"when": 1779573019125,
"tag": "0090_resource_memberships",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1778810394522,
"tag": "0091_old_swarm",
"breakpoints": true
}
]
}
@@ -0,0 +1,42 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationAnchorState,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { documentAnnotationThreads } from "./document_annotation_threads.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
export const documentAnnotationAnchorSnapshots = pgTable(
"document_annotation_anchor_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
fromRevisionNumber: integer("from_revision_number"),
toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
toRevisionNumber: integer("to_revision_number").notNull(),
previousAnchor: jsonb("previous_anchor").$type<DocumentAnnotationAnchorSnapshot>().notNull(),
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull(),
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyThreadCreatedAtIdx: index("document_annotation_anchor_snapshots_company_thread_created_at_idx").on(
table.companyId,
table.threadId,
table.createdAt,
),
companyDocumentRevisionIdx: index("document_annotation_anchor_snapshots_company_document_revision_idx").on(
table.companyId,
table.documentId,
table.toRevisionNumber,
),
}),
);
@@ -0,0 +1,44 @@
import type { IssueCommentAuthorType } from "@paperclipai/shared";
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentAnnotationThreads } from "./document_annotation_threads.js";
import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
export const documentAnnotationComments = pgTable(
"document_annotation_comments",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
authorUserId: text("author_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyThreadCreatedAtIdx: index("document_annotation_comments_company_thread_created_at_idx").on(
table.companyId,
table.threadId,
table.createdAt,
),
companyIssueCreatedAtIdx: index("document_annotation_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
table.companyId,
table.documentId,
table.createdAt,
),
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
}),
);
@@ -0,0 +1,70 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
export const documentAnnotationThreads = pgTable(
"document_annotation_threads",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
documentKey: text("document_key").notNull(),
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
originalRevisionNumber: integer("original_revision_number").notNull(),
currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
currentRevisionNumber: integer("current_revision_number").notNull(),
selectedText: text("selected_text").notNull(),
prefixText: text("prefix_text").notNull().default(""),
suffixText: text("suffix_text").notNull().default(""),
normalizedStart: integer("normalized_start").notNull(),
normalizedEnd: integer("normalized_end").notNull(),
markdownStart: integer("markdown_start").notNull(),
markdownEnd: integer("markdown_end").notNull(),
anchorConfidence: text("anchor_confidence")
.$type<DocumentAnnotationAnchorConfidence>()
.notNull()
.default("exact"),
anchorSelector: jsonb("anchor_selector").$type<DocumentAnnotationAnchorSelector>().notNull(),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
resolvedByUserId: text("resolved_by_user_id"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyDocumentStatusIdx: index("document_annotation_threads_company_document_status_idx").on(
table.companyId,
table.documentId,
table.status,
),
companyIssueStatusIdx: index("document_annotation_threads_company_issue_status_idx").on(
table.companyId,
table.issueId,
table.status,
),
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
table.companyId,
table.documentId,
table.currentRevisionId,
table.status,
),
companyAnchorStateIdx: index("document_annotation_threads_company_anchor_state_idx").on(
table.companyId,
table.anchorState,
),
}),
);
+3
View File
@@ -55,6 +55,9 @@ export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js";
export { documentAnnotationThreads } from "./document_annotation_threads.js";
export { documentAnnotationComments } from "./document_annotation_comments.js";
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
+16
View File
@@ -281,6 +281,22 @@ export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumen
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const;
export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number];
export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const;
export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number];
export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
"exact",
"duplicate",
"fuzzy",
"ambiguous",
"missing",
] as const;
export type DocumentAnnotationAnchorConfidence =
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
+464
View File
@@ -0,0 +1,464 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorState,
} from "./constants.js";
import type {
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
} from "./types/document-annotation.js";
export interface CreateDocumentAnchorSelectorOptions {
contextLength?: number;
}
export interface VerifyDocumentAnchorSelectorInput {
markdown: string;
selector: DocumentAnnotationAnchorSelector;
contextLength?: number;
}
export interface VerifyDocumentAnchorSelectorResult {
ok: boolean;
anchor: DocumentAnnotationAnchorSnapshot | null;
projection: DocumentTextProjection;
reason: "verified" | "quote_mismatch" | "position_mismatch" | "invalid_range";
}
export interface RemapDocumentAnchorInput {
previousAnchor: DocumentAnnotationAnchorSnapshot;
nextMarkdown: string;
contextLength?: number;
}
export interface RemapDocumentAnchorResult {
anchorState: DocumentAnnotationAnchorState;
confidence: DocumentAnnotationAnchorConfidence;
anchor: DocumentAnnotationAnchorSnapshot | null;
projection: DocumentTextProjection;
reason: "exact" | "duplicate" | "fuzzy" | "ambiguous" | "missing";
}
interface Candidate {
start: number;
end: number;
score: number;
reason: RemapDocumentAnchorResult["reason"];
}
const DEFAULT_CONTEXT_LENGTH = 48;
export function normalizeAnchorText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function projectMarkdownToText(markdown: string): DocumentTextProjection {
const builder = new ProjectionBuilder(markdown);
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? [markdown];
let offset = 0;
let inFence = false;
for (const rawLine of lines) {
if (rawLine === "") continue;
const hasNewline = rawLine.endsWith("\n");
const line = hasNewline ? rawLine.slice(0, -1) : rawLine;
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
inFence = !inFence;
offset += rawLine.length;
builder.addSeparator(offset - (hasNewline ? 1 : 0));
continue;
}
if (inFence) {
builder.addText(line, offset);
builder.addSeparator(offset + line.length);
offset += rawLine.length;
continue;
}
const { text, sourceOffset } = stripBlockSyntax(line, offset);
addInlineMarkdownText(builder, text, sourceOffset);
builder.addSeparator(offset + line.length);
offset += rawLine.length;
}
return builder.toProjection();
}
export function resolveProjectionRange(
projection: DocumentTextProjection,
normalizedStart: number,
normalizedEnd: number,
): DocumentTextRange | null {
if (
normalizedStart < 0
|| normalizedEnd <= normalizedStart
|| normalizedEnd > projection.text.length
|| normalizedStart >= projection.positions.length
|| normalizedEnd - 1 >= projection.positions.length
) {
return null;
}
return {
text: projection.text.slice(normalizedStart, normalizedEnd),
normalizedStart,
normalizedEnd,
markdownStart: projection.positions[normalizedStart]?.sourceStart ?? 0,
markdownEnd: projection.positions[normalizedEnd - 1]?.sourceEnd ?? 0,
};
}
export function createDocumentAnchorSelector(
projection: DocumentTextProjection,
range: DocumentTextRange,
options: CreateDocumentAnchorSelectorOptions = {},
): DocumentAnnotationAnchorSelector {
const contextLength = options.contextLength ?? DEFAULT_CONTEXT_LENGTH;
return {
quote: {
exact: range.text,
prefix: projection.text.slice(Math.max(0, range.normalizedStart - contextLength), range.normalizedStart),
suffix: projection.text.slice(range.normalizedEnd, range.normalizedEnd + contextLength),
},
position: {
normalizedStart: range.normalizedStart,
normalizedEnd: range.normalizedEnd,
markdownStart: range.markdownStart,
markdownEnd: range.markdownEnd,
},
};
}
export function selectorToAnchorSnapshot(selector: DocumentAnnotationAnchorSelector): DocumentAnnotationAnchorSnapshot {
return {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
}
export function anchorSnapshotToSelector(anchor: DocumentAnnotationAnchorSnapshot): DocumentAnnotationAnchorSelector {
return {
quote: {
exact: anchor.selectedText,
prefix: anchor.prefixText,
suffix: anchor.suffixText,
},
position: {
normalizedStart: anchor.normalizedStart,
normalizedEnd: anchor.normalizedEnd,
markdownStart: anchor.markdownStart,
markdownEnd: anchor.markdownEnd,
},
};
}
export function verifyDocumentAnchorSelector(
input: VerifyDocumentAnchorSelectorInput,
): VerifyDocumentAnchorSelectorResult {
const projection = projectMarkdownToText(input.markdown);
const range = resolveProjectionRange(
projection,
input.selector.position.normalizedStart,
input.selector.position.normalizedEnd,
);
if (!range) {
return { ok: false, anchor: null, projection, reason: "invalid_range" };
}
if (normalizeAnchorText(range.text) !== normalizeAnchorText(input.selector.quote.exact)) {
return { ok: false, anchor: null, projection, reason: "quote_mismatch" };
}
if (
range.markdownStart !== input.selector.position.markdownStart
|| range.markdownEnd !== input.selector.position.markdownEnd
) {
return { ok: false, anchor: null, projection, reason: "position_mismatch" };
}
const selector = createDocumentAnchorSelector(projection, range, {
contextLength: input.contextLength ?? DEFAULT_CONTEXT_LENGTH,
});
return { ok: true, anchor: selectorToAnchorSnapshot(selector), projection, reason: "verified" };
}
export function remapDocumentAnchor(input: RemapDocumentAnchorInput): RemapDocumentAnchorResult {
const projection = projectMarkdownToText(input.nextMarkdown);
const contextLength = input.contextLength ?? DEFAULT_CONTEXT_LENGTH;
const quote = normalizeAnchorText(input.previousAnchor.selectedText);
if (!quote) {
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
}
const exactCandidates = findOccurrences(projection.text, quote).map((start) => scoreCandidate({
projection,
start,
end: start + quote.length,
previousAnchor: input.previousAnchor,
reason: "exact",
contextLength,
}));
if (exactCandidates.length > 0) {
exactCandidates.sort((a, b) => b.score - a.score);
const [best, second] = exactCandidates;
if (exactCandidates.length > 1 && (!second || Math.abs(best.score - second.score) < 0.05)) {
return {
anchorState: "stale",
confidence: "ambiguous",
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
projection,
reason: "ambiguous",
};
}
return {
anchorState: "active",
confidence: exactCandidates.length === 1 ? "exact" : "duplicate",
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
projection,
reason: exactCandidates.length === 1 ? "exact" : "duplicate",
};
}
const fuzzy = findFuzzyCandidate(projection, input.previousAnchor, contextLength);
if (fuzzy && fuzzy.score >= 0.58) {
return {
anchorState: "stale",
confidence: "fuzzy",
anchor: buildAnchorSnapshot(projection, fuzzy.start, fuzzy.end, contextLength),
projection,
reason: "fuzzy",
};
}
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
}
function stripBlockSyntax(line: string, absoluteOffset: number): { text: string; sourceOffset: number } {
const blockMatch = line.match(/^\s{0,3}(?:(#{1,6})\s+|(?:[-+*]|\d+[.)])\s+|>\s?)/);
if (!blockMatch) return { text: line, sourceOffset: absoluteOffset };
return { text: line.slice(blockMatch[0].length), sourceOffset: absoluteOffset + blockMatch[0].length };
}
function addInlineMarkdownText(builder: ProjectionBuilder, text: string, sourceOffset: number): void {
for (let index = 0; index < text.length; index += 1) {
const char = text[index] ?? "";
const absolute = sourceOffset + index;
const rest = text.slice(index);
const image = rest.match(/^!\[([^\]]*)\]\(([^)]*)\)/);
if (image) {
const altStart = absolute + 2;
builder.addText(image[1] ?? "", altStart);
index += image[0].length - 1;
continue;
}
const link = rest.match(/^\[([^\]]+)\]\(([^)]*)\)/);
if (link) {
const labelStart = absolute + 1;
builder.addText(link[1] ?? "", labelStart);
index += link[0].length - 1;
continue;
}
if (char === "`") {
const closing = text.indexOf("`", index + 1);
if (closing > index + 1) {
builder.addText(text.slice(index + 1, closing), absolute + 1);
index = closing;
continue;
}
}
if (char === "|" || char === "\t") {
builder.addSeparator(absolute);
continue;
}
if (isMarkdownFormattingChar(char, text, index)) continue;
builder.addChar(char, absolute, absolute + 1);
}
}
function isMarkdownFormattingChar(char: string, text: string, index: number): boolean {
if (char === "*" || char === "_" || char === "~") return true;
if (char === "\\" && index + 1 < text.length) return true;
return false;
}
function findOccurrences(text: string, quote: string): number[] {
const starts: number[] = [];
let start = text.indexOf(quote);
while (start !== -1) {
starts.push(start);
start = text.indexOf(quote, start + 1);
}
return starts;
}
function scoreCandidate(args: {
projection: DocumentTextProjection;
start: number;
end: number;
previousAnchor: DocumentAnnotationAnchorSnapshot;
reason: Candidate["reason"];
contextLength: number;
}): Candidate {
const before = args.projection.text.slice(Math.max(0, args.start - args.contextLength), args.start);
const after = args.projection.text.slice(args.end, args.end + args.contextLength);
const prefixScore = suffixOverlapScore(args.previousAnchor.prefixText, before);
const suffixScore = prefixOverlapScore(args.previousAnchor.suffixText, after);
const distance = Math.abs(args.start - args.previousAnchor.normalizedStart);
const proximity = 1 / (1 + distance / 200);
return {
start: args.start,
end: args.end,
score: prefixScore * 0.35 + suffixScore * 0.35 + proximity * 0.3,
reason: args.reason,
};
}
function findFuzzyCandidate(
projection: DocumentTextProjection,
previousAnchor: DocumentAnnotationAnchorSnapshot,
contextLength: number,
): Candidate | null {
const words = normalizeAnchorText(previousAnchor.selectedText).split(" ").filter(Boolean);
if (words.length === 0) return null;
const textWords = [...projection.text.matchAll(/\S+/g)].map((match) => ({
text: match[0],
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
}));
const windowSizes = new Set([words.length - 1, words.length, words.length + 1, words.length + 2].filter((n) => n > 0));
let best: Candidate | null = null;
for (const size of windowSizes) {
for (let index = 0; index + size <= textWords.length; index += 1) {
const window = textWords.slice(index, index + size);
const candidateText = window.map((word) => word.text).join(" ");
const similarity = similarityScore(normalizeAnchorText(previousAnchor.selectedText), candidateText);
if (similarity < 0.45) continue;
const scored = scoreCandidate({
projection,
start: window[0]?.start ?? 0,
end: window[window.length - 1]?.end ?? 0,
previousAnchor,
reason: "fuzzy",
contextLength,
});
scored.score = scored.score * 0.35 + similarity * 0.65;
if (!best || scored.score > best.score) best = scored;
}
}
return best;
}
function buildAnchorSnapshot(
projection: DocumentTextProjection,
normalizedStart: number,
normalizedEnd: number,
contextLength: number,
): DocumentAnnotationAnchorSnapshot {
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
if (!range) {
return {
selectedText: "",
prefixText: "",
suffixText: "",
normalizedStart,
normalizedEnd,
markdownStart: 0,
markdownEnd: 0,
};
}
const selector = createDocumentAnchorSelector(projection, range, { contextLength });
return selectorToAnchorSnapshot(selector);
}
function prefixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
const expected = normalizeAnchorText(expectedPrefix);
const actual = normalizeAnchorText(actualPrefix);
if (!expected) return 0.5;
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
if (expected.slice(0, size) === actual.slice(0, size)) return size / expected.length;
}
return 0;
}
function suffixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
const expected = normalizeAnchorText(expectedPrefix);
const actual = normalizeAnchorText(actualPrefix);
if (!expected) return 0.5;
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
if (expected.slice(-size) === actual.slice(-size)) return size / expected.length;
}
return 0;
}
function similarityScore(left: string, right: string): number {
if (left === right) return 1;
const leftWords = new Set(left.toLowerCase().split(/\s+/).filter(Boolean));
const rightWords = new Set(right.toLowerCase().split(/\s+/).filter(Boolean));
const intersection = [...leftWords].filter((word) => rightWords.has(word)).length;
const union = new Set([...leftWords, ...rightWords]).size || 1;
const jaccard = intersection / union;
const lengthRatio = Math.min(left.length, right.length) / Math.max(left.length, right.length, 1);
return jaccard * 0.75 + lengthRatio * 0.25;
}
class ProjectionBuilder {
private text = "";
private positions: DocumentTextPosition[] = [];
private pendingSpace: DocumentTextPosition | null = null;
constructor(private readonly source: string) {}
addText(text: string, sourceOffset: number): void {
for (let index = 0; index < text.length; index += 1) {
this.addChar(text[index] ?? "", sourceOffset + index, sourceOffset + index + 1);
}
}
addSeparator(sourceOffset: number): void {
this.addChar(" ", sourceOffset, sourceOffset + 1);
}
addChar(char: string, sourceStart: number, sourceEnd: number): void {
if (/\s/.test(char)) {
if (this.text.length > 0 && !this.pendingSpace) {
this.pendingSpace = { sourceStart, sourceEnd };
}
return;
}
if (this.pendingSpace && this.text.length > 0) {
this.text += " ";
this.positions.push(this.pendingSpace);
}
this.pendingSpace = null;
this.text += char;
this.positions.push({ sourceStart, sourceEnd });
}
toProjection(): DocumentTextProjection {
return {
source: this.source,
text: this.text,
positions: this.positions,
};
}
}
+74 -2
View File
@@ -45,6 +45,9 @@ export {
SYSTEM_ISSUE_DOCUMENT_KEYS,
isSystemIssueDocumentKey,
ISSUE_REFERENCE_SOURCE_KINDS,
DOCUMENT_ANNOTATION_THREAD_STATUSES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_MONITOR_SCHEDULED_BY,
@@ -164,6 +167,9 @@ export {
type IssueTreeHoldStatus,
type SystemIssueDocumentKey,
type IssueReferenceSourceKind,
type DocumentAnnotationThreadStatus,
type DocumentAnnotationAnchorState,
type DocumentAnnotationAnchorConfidence,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueMonitorScheduledBy,
@@ -290,6 +296,13 @@ export type {
CompanySkillUsageAgent,
CompanySkillDetail,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
CompanySkillUpdateHoldReason,
CompanySkillAuditFinding,
CompanySkillAuditResult,
CompanySkillInstallUpdateRequest,
CompanySkillResetRequest,
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
@@ -299,6 +312,14 @@ export type {
CompanySkillCreateRequest,
CompanySkillFileDetail,
CompanySkillFileUpdateRequest,
CatalogSkillKind,
CatalogSkillFileKind,
CatalogSkillFile,
CatalogSkill,
CatalogSkillListQuery,
CatalogSkillFileDetail,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
AgentSkillSyncMode,
AgentSkillState,
AgentSkillOrigin,
@@ -376,6 +397,20 @@ export type {
IssueWorkProductProvider,
IssueWorkProductStatus,
IssueWorkProductReviewState,
CreateDocumentAnnotationCommentRequest,
CreateDocumentAnnotationThreadRequest,
DocumentAnnotationAnchorRemapSnapshot,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationComment,
DocumentAnnotationTextPositionSelector,
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
Issue,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
@@ -551,7 +586,6 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
CompanyPortabilitySecretEntry,
EnvBinding,
EnvPlainBinding,
EnvSecretRefBinding,
@@ -655,6 +689,22 @@ export {
type IssueReferenceMatch,
} from "./issue-references.js";
export {
anchorSnapshotToSelector,
createDocumentAnchorSelector,
normalizeAnchorText,
projectMarkdownToText,
remapDocumentAnchor,
resolveProjectionRange,
selectorToAnchorSnapshot,
verifyDocumentAnchorSelector,
type CreateDocumentAnchorSelectorOptions,
type RemapDocumentAnchorInput,
type RemapDocumentAnchorResult,
type VerifyDocumentAnchorSelectorInput,
type VerifyDocumentAnchorSelectorResult,
} from "./document-anchors.js";
export {
sidebarOrderPreferenceSchema,
upsertSidebarOrderPreferenceSchema,
@@ -796,6 +846,18 @@ export {
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
documentAnnotationAnchorConfidenceSchema,
documentAnnotationAnchorSelectorSchema,
documentAnnotationAnchorStateSchema,
documentAnnotationTextPositionSelectorSchema,
documentAnnotationTextQuoteSelectorSchema,
documentAnnotationThreadStatusSchema,
updateDocumentAnnotationThreadSchema,
type CreateDocumentAnnotationComment,
type CreateDocumentAnnotationThread,
type UpdateDocumentAnnotationThread,
companySearchQuerySchema,
COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_MAX_LIMIT,
@@ -1013,8 +1075,9 @@ export {
companySkillUsageAgentSchema,
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -1022,6 +1085,15 @@ export {
companySkillCreateSchema,
companySkillFileDetailSchema,
companySkillFileUpdateSchema,
catalogSkillKindSchema,
catalogSkillFileSchema,
catalogSkillSchema,
catalogSkillListQuerySchema,
catalogSkillFileDetailSchema,
companySkillInstallCatalogSchema,
companySkillInstallCatalogResultSchema,
companySkillInstallUpdateSchema,
companySkillResetSchema,
portabilityIncludeSchema,
portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema,
+108
View File
@@ -51,6 +51,10 @@ export interface CompanySkillListItem {
sourceLabel: string | null;
sourceBadge: CompanySkillSourceBadge;
sourcePath: string | null;
catalogKind: "bundled" | "optional" | null;
originHash: string | null;
packageName: string | null;
packageVersion: string | null;
}
export interface CompanySkillUsageAgent {
@@ -84,6 +88,49 @@ export interface CompanySkillUpdateStatus {
currentRef: string | null;
latestRef: string | null;
hasUpdate: boolean;
installedHash: string | null;
originHash: string | null;
userModifiedAt: string | null;
updateHoldReason: CompanySkillUpdateHoldReason | null;
auditVerdict: CompanySkillAuditVerdict | null;
auditCodes: string[];
}
export type CompanySkillAuditSeverity = "warning" | "error";
export type CompanySkillAuditVerdict = "pass" | "warning" | "fail";
export type CompanySkillUpdateHoldReason =
| "local_modifications"
| "audit_hard_stop"
| "origin_unavailable"
| "compatibility_invalid"
| "operator_hold";
export interface CompanySkillAuditFinding {
code: string;
severity: CompanySkillAuditSeverity;
message: string;
path: string | null;
}
export interface CompanySkillAuditResult {
skillId: string;
installedHash: string | null;
originHash: string | null;
verdict: CompanySkillAuditVerdict;
codes: string[];
findings: CompanySkillAuditFinding[];
scannedAt: string;
scanVersion: string;
}
export interface CompanySkillInstallUpdateRequest {
force?: boolean;
}
export interface CompanySkillResetRequest {
force?: boolean;
}
export interface CompanySkillImportRequest {
@@ -155,3 +202,64 @@ export interface CompanySkillFileUpdateRequest {
path: string;
content: string;
}
export type CatalogSkillKind = "bundled" | "optional";
export type CatalogSkillFileKind = CompanySkillFileInventoryEntry["kind"];
export interface CatalogSkillFile {
path: string;
kind: CatalogSkillFileKind;
sizeBytes: number;
sha256: string;
}
export interface CatalogSkill {
id: string;
key: string;
kind: CatalogSkillKind;
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: CompanySkillTrustLevel;
compatibility: CompanySkillCompatibility;
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
packageName?: string;
packageVersion?: string;
}
export interface CatalogSkillListQuery {
kind?: CatalogSkillKind;
category?: string;
q?: string;
}
export interface CatalogSkillFileDetail {
catalogSkillId: string;
path: string;
kind: CatalogSkillFileKind;
content: string;
language: string | null;
markdown: boolean;
}
export interface CompanySkillInstallCatalogRequest {
catalogSkillId: string;
slug?: string | null;
force?: boolean;
}
export interface CompanySkillInstallCatalogResult {
action: "created" | "updated" | "unchanged";
skill: CompanySkill;
catalogSkill: CatalogSkill;
warnings: string[];
}
@@ -0,0 +1,134 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
IssueCommentAuthorType,
} from "../constants.js";
export interface DocumentTextPosition {
sourceStart: number;
sourceEnd: number;
}
export interface DocumentTextProjection {
source: string;
text: string;
positions: DocumentTextPosition[];
}
export interface DocumentTextRange {
text: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationTextQuoteSelector {
exact: string;
prefix: string;
suffix: string;
}
export interface DocumentAnnotationTextPositionSelector {
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationAnchorSelector {
quote: DocumentAnnotationTextQuoteSelector;
position: DocumentAnnotationTextPositionSelector;
}
export interface DocumentAnnotationAnchorSnapshot {
selectedText: string;
prefixText: string;
suffixText: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationThread {
id: string;
companyId: string;
issueId: string;
documentId: string;
documentKey: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
originalRevisionId: string | null;
originalRevisionNumber: number;
currentRevisionId: string | null;
currentRevisionNumber: number;
selectedText: string;
prefixText: string;
suffixText: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
anchorSelector: DocumentAnnotationAnchorSelector;
createdByAgentId: string | null;
createdByUserId: string | null;
resolvedByAgentId: string | null;
resolvedByUserId: string | null;
resolvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentAnnotationComment {
id: string;
companyId: string;
threadId: string;
issueId: string;
documentId: string;
body: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
createdByRunId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentAnnotationAnchorRemapSnapshot {
id: string;
companyId: string;
threadId: string;
documentId: string;
fromRevisionId: string | null;
fromRevisionNumber: number | null;
toRevisionId: string | null;
toRevisionNumber: number;
previousAnchor: DocumentAnnotationAnchorSnapshot;
nextAnchor: DocumentAnnotationAnchorSnapshot | null;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
failureReason: string | null;
createdAt: Date;
}
export interface DocumentAnnotationThreadWithComments extends DocumentAnnotationThread {
comments: DocumentAnnotationComment[];
}
export interface CreateDocumentAnnotationThreadRequest {
baseRevisionId: string;
baseRevisionNumber: number;
selector: DocumentAnnotationAnchorSelector;
body: string;
}
export interface CreateDocumentAnnotationCommentRequest {
body: string;
}
export interface UpdateDocumentAnnotationThreadRequest {
status?: DocumentAnnotationThreadStatus;
}
+31 -1
View File
@@ -51,6 +51,13 @@ export type {
CompanySkillUsageAgent,
CompanySkillDetail,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
CompanySkillUpdateHoldReason,
CompanySkillAuditFinding,
CompanySkillAuditResult,
CompanySkillInstallUpdateRequest,
CompanySkillResetRequest,
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
@@ -60,6 +67,14 @@ export type {
CompanySkillCreateRequest,
CompanySkillFileDetail,
CompanySkillFileUpdateRequest,
CatalogSkillKind,
CatalogSkillFileKind,
CatalogSkillFile,
CatalogSkill,
CatalogSkillListQuery,
CatalogSkillFileDetail,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
} from "./company-skill.js";
export type {
AgentSkillSyncMode,
@@ -89,6 +104,22 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type {
CreateDocumentAnnotationCommentRequest,
CreateDocumentAnnotationThreadRequest,
DocumentAnnotationAnchorRemapSnapshot,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationComment,
DocumentAnnotationTextPositionSelector,
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchHighlight,
@@ -386,7 +417,6 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
CompanyPortabilitySecretEntry,
} from "./company-portability.js";
export type {
JsonSchema,
+105 -6
View File
@@ -35,6 +35,10 @@ export const companySkillListItemSchema = companySkillSchema.extend({
editableReason: z.string().nullable(),
sourceLabel: z.string().nullable(),
sourceBadge: companySkillSourceBadgeSchema,
catalogKind: z.enum(["bundled", "optional"]).nullable(),
originHash: z.string().nullable(),
packageName: z.string().nullable(),
packageVersion: z.string().nullable(),
});
export const companySkillUsageAgentSchema = z.object({
@@ -64,15 +68,48 @@ export const companySkillUpdateStatusSchema = z.object({
currentRef: z.string().nullable(),
latestRef: z.string().nullable(),
hasUpdate: z.boolean(),
installedHash: z.string().nullable(),
originHash: z.string().nullable(),
userModifiedAt: z.string().nullable(),
updateHoldReason: z.enum([
"local_modifications",
"audit_hard_stop",
"origin_unavailable",
"compatibility_invalid",
"operator_hold",
]).nullable(),
auditVerdict: z.enum(["pass", "warning", "fail"]).nullable(),
auditCodes: z.array(z.string()),
});
export const companySkillAuditFindingSchema = z.object({
code: z.string().min(1),
severity: z.enum(["warning", "error"]),
message: z.string().min(1),
path: z.string().nullable(),
});
export const companySkillAuditResultSchema = z.object({
skillId: z.string().uuid(),
installedHash: z.string().nullable(),
originHash: z.string().nullable(),
verdict: z.enum(["pass", "warning", "fail"]),
codes: z.array(z.string()),
findings: z.array(companySkillAuditFindingSchema),
scannedAt: z.string().min(1),
scanVersion: z.string().min(1),
});
export const companySkillInstallUpdateSchema = z.object({
force: z.boolean().optional(),
}).default({});
export const companySkillResetSchema = z.object({
force: z.boolean().optional(),
}).default({});
export const companySkillImportSchema = z.object({
source: z.string().min(1),
authToken: z.string().min(1).optional(),
});
export const companySkillUpdateAuthSchema = z.object({
authToken: z.string().min(1).nullable(),
});
export const companySkillProjectScanRequestSchema = z.object({
@@ -136,8 +173,70 @@ export const companySkillFileUpdateSchema = z.object({
content: z.string(),
});
export const catalogSkillKindSchema = z.enum(["bundled", "optional"]);
export const catalogSkillFileSchema = z.object({
path: z.string().min(1),
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
sizeBytes: z.number().int().nonnegative(),
sha256: z.string().min(1),
});
export const catalogSkillSchema = z.object({
id: z.string().min(1),
key: z.string().min(1),
kind: catalogSkillKindSchema,
category: z.string().min(1),
slug: z.string().min(1),
name: z.string().min(1),
description: z.string(),
path: z.string().min(1),
entrypoint: z.literal("SKILL.md"),
trustLevel: companySkillTrustLevelSchema,
compatibility: companySkillCompatibilitySchema,
defaultInstall: z.boolean(),
recommendedForRoles: z.array(z.string()),
requires: z.array(z.string()),
tags: z.array(z.string()),
files: z.array(catalogSkillFileSchema),
contentHash: z.string().min(1),
packageName: z.string().min(1).optional(),
packageVersion: z.string().min(1).optional(),
});
export const catalogSkillListQuerySchema = z.object({
kind: catalogSkillKindSchema.optional(),
category: z.string().min(1).optional(),
q: z.string().min(1).optional(),
});
export const catalogSkillFileDetailSchema = z.object({
catalogSkillId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
content: z.string(),
language: z.string().nullable(),
markdown: z.boolean(),
});
export const companySkillInstallCatalogSchema = z.object({
catalogSkillId: z.string().min(1),
slug: z.string().min(1).nullable().optional(),
force: z.boolean().optional(),
});
export const companySkillInstallCatalogResultSchema = z.object({
action: z.enum(["created", "updated", "unchanged"]),
skill: companySkillSchema,
catalogSkill: catalogSkillSchema,
warnings: z.array(z.string()),
});
export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
export type CatalogSkillListQuery = z.infer<typeof catalogSkillListQuerySchema>;
export type CompanySkillInstallCatalog = z.infer<typeof companySkillInstallCatalogSchema>;
export type CompanySkillInstallUpdate = z.infer<typeof companySkillInstallUpdateSchema>;
export type CompanySkillReset = z.infer<typeof companySkillResetSchema>;
@@ -0,0 +1,65 @@
import { z } from "zod";
import {
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_THREAD_STATUSES,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
export const documentAnnotationThreadStatusSchema = z.enum(DOCUMENT_ANNOTATION_THREAD_STATUSES);
export const documentAnnotationAnchorStateSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_STATES);
export const documentAnnotationAnchorConfidenceSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES);
export const documentAnnotationTextQuoteSelectorSchema = z.object({
exact: z.string().min(1).max(10_000),
prefix: z.string().max(1_000).default(""),
suffix: z.string().max(1_000).default(""),
}).strict();
export const documentAnnotationTextPositionSelectorSchema = z.object({
normalizedStart: z.number().int().nonnegative(),
normalizedEnd: z.number().int().nonnegative(),
markdownStart: z.number().int().nonnegative(),
markdownEnd: z.number().int().nonnegative(),
}).strict().superRefine((value, ctx) => {
if (value.normalizedEnd <= value.normalizedStart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "normalizedEnd must be greater than normalizedStart",
path: ["normalizedEnd"],
});
}
if (value.markdownEnd <= value.markdownStart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "markdownEnd must be greater than markdownStart",
path: ["markdownEnd"],
});
}
});
export const documentAnnotationAnchorSelectorSchema = z.object({
quote: documentAnnotationTextQuoteSelectorSchema,
position: documentAnnotationTextPositionSelectorSchema,
}).strict();
export const createDocumentAnnotationThreadSchema = z.object({
baseRevisionId: z.string().uuid(),
baseRevisionNumber: z.number().int().positive(),
selector: documentAnnotationAnchorSelectorSchema,
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
}).strict();
export const createDocumentAnnotationCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
}).strict();
export const updateDocumentAnnotationThreadSchema = z.object({
status: documentAnnotationThreadStatusSchema.optional(),
}).strict().refine((value) => value.status != null, {
message: "At least one field must be provided",
});
export type CreateDocumentAnnotationThread = z.infer<typeof createDocumentAnnotationThreadSchema>;
export type CreateDocumentAnnotationComment = z.infer<typeof createDocumentAnnotationCommentSchema>;
export type UpdateDocumentAnnotationThread = z.infer<typeof updateDocumentAnnotationThreadSchema>;
+30 -2
View File
@@ -67,8 +67,9 @@ export {
companySkillUsageAgentSchema,
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -76,11 +77,23 @@ export {
companySkillCreateSchema,
companySkillFileDetailSchema,
companySkillFileUpdateSchema,
catalogSkillKindSchema,
catalogSkillFileSchema,
catalogSkillSchema,
catalogSkillListQuerySchema,
catalogSkillFileDetailSchema,
companySkillInstallCatalogSchema,
companySkillInstallCatalogResultSchema,
companySkillInstallUpdateSchema,
companySkillResetSchema,
type CompanySkillImport,
type CompanySkillProjectScan,
type CompanySkillCreate,
type CompanySkillFileUpdate,
type CompanySkillUpdateAuth,
type CatalogSkillListQuery,
type CompanySkillInstallCatalog,
type CompanySkillInstallUpdate,
type CompanySkillReset,
} from "./company-skill.js";
export {
agentSkillStateSchema,
@@ -154,6 +167,21 @@ export {
type ProjectExecutionWorkspacePolicy,
} from "./project.js";
export {
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
documentAnnotationAnchorConfidenceSchema,
documentAnnotationAnchorSelectorSchema,
documentAnnotationAnchorStateSchema,
documentAnnotationTextPositionSelectorSchema,
documentAnnotationTextQuoteSelectorSchema,
documentAnnotationThreadStatusSchema,
updateDocumentAnnotationThreadSchema,
type CreateDocumentAnnotationComment,
type CreateDocumentAnnotationThread,
type UpdateDocumentAnnotationThread,
} from "./document-annotation.js";
export {
createIssueSchema,
createIssueInputSchema,
@@ -0,0 +1,75 @@
---
name: doc-maintenance
description: Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.
key: paperclipai/bundled/docs/doc-maintenance
recommendedForRoles:
- engineer
- product
- devrel
tags:
- docs
- documentation
- release-notes
---
# Doc Maintenance
Keep the documentation honest with minimum churn. The goal is alignment between docs and behavior, not stylistic rewrites or cosmetic re-organization. Reviewers should be able to read a diff and see "this updates docs to match recent behavior changes".
## When to use
- A PR or recent set of merges changed user-visible behavior: CLI flags, API shapes, default values, configuration keys, endpoints, environment variables, supported versions.
- A user-reported bug traced back to outdated documentation.
- A release is being cut and the docs need a pass against the merged commits.
- A new feature shipped but only the engineer's PR description describes how to use it.
## When not to use
- The change is internal-only (private helper rename, refactor) with no user-visible impact.
- You want to "improve the docs" without a behavior anchor. That is a separate scoped project, not maintenance — make a plan first.
## The pass
1. **Establish the baseline.** Get the commit range you are documenting against (since last release tag, since last merged-doc commit, or since a specific PR).
2. **Enumerate user-visible changes.** Read commits and PR descriptions. List, for each change, what a user can now do differently.
3. **Map changes to docs.** For each change, find every page that mentions the affected concept. Common targets: README, CLI reference, API reference, configuration reference, migration guide, FAQ, examples.
4. **Update precisely.** Edit only the lines that need to change. Do not rewrap paragraphs you did not modify — it pollutes the diff.
5. **Add new entries where needed.** New CLI flag → CLI reference entry. New env var → configuration reference entry. New endpoint → API reference entry. Don't only add it to the changelog.
6. **Update examples and snippets.** Code blocks in docs are wrong faster than prose. Re-run any example that touches new behavior.
7. **Write the release note.** One sentence per user-visible change. Group by Added / Changed / Fixed / Deprecated / Removed. Link to the relevant PRs and docs section.
8. **Cross-check.** Search the docs for the old behavior wording and remove or update stragglers.
## Style baseline
- Voice: second person ("you can pass `--json` to ..."). Avoid "we" except in narrative pages.
- Tense: present, not future. The behavior exists once shipped.
- Headings: imperative ("Configure the cache") or noun-phrase ("Cache configuration"), match the surrounding page.
- Code blocks: include the language tag so syntax highlighting works.
- Cross-links: link the first mention of a concept on each page; do not link every occurrence.
- Avoid promising future behavior. If something is unreleased, mark it `experimental` or omit it.
## Drift detection
A doc page is drifting if any of these are true:
- It documents a flag, key, or endpoint that no longer exists.
- An example does not run as written.
- A default value in the docs does not match the code.
- A supported-versions list excludes a version the project actually supports, or includes one it dropped.
- A "Coming soon" section references a feature that shipped or was cancelled.
When you find drift, fix it in the same pass and note it in the release note's `Fixed` group.
## Release-note rules
- One sentence per item. If two sentences are needed, the item is likely two items.
- User impact first, internal cause second. `Faster cold start (avoid full bundle download on first run)` beats `Refactor bootstrap loader`.
- Link the PR for engineering readers and the docs page for users.
- Mark breaking changes explicitly: `**Breaking:**` prefix. Include migration steps inline or via link.
## Anti-patterns
- Massive doc PRs that bundle stylistic rewrites with real updates. Reviewers cannot tell which lines reflect actual behavior changes.
- "Updated docs" commit messages with no detail. Make the commit say what changed and why.
- Adding to the changelog without updating the reference docs the changelog points to.
- Marking a feature as available before its code lands. Documentation must follow behavior, not promise it.
@@ -0,0 +1,74 @@
---
name: issue-triage
description: Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).
key: paperclipai/bundled/paperclip-operations/issue-triage
recommendedForRoles:
- manager
- ceo
- engineer
tags:
- paperclip
- triage
- inbox
- workflow
---
# Issue Triage
Convert a noisy inbox into a small set of clear next actions. Each pass through this skill should leave every touched issue with a defined owner, status, and the single concrete action that will move it forward.
## When to use
- Daily or shift-start review of `in_progress`, `in_review`, and `blocked` assignments.
- An inbox has many open assignments and no clear priority.
- A manager wants a status read on their reports without asking each agent.
- You are woken by a comment that suggests an old issue stalled.
## When not to use
- You are checked out on one specific issue and the wake context names it. Work that issue, do not triage the whole inbox.
- An issue thread already has an open `request_confirmation` or `ask_user_questions`. Wait for the response — re-triage is noise.
## Inputs
- `GET /api/agents/me/inbox-lite` for the compact assignment list.
- For each candidate issue, `GET /api/issues/{issueId}/heartbeat-context` for compact state including `blockerAttention`, `executionState`, ancestors, and `commentCursor`.
- Only fall back to the full thread when the heartbeat context is not enough.
## Per-issue triage decision
For each issue, classify into exactly one of:
1. **Resume** — execution path is alive. Confirm the assignee is set and let the heartbeat continue. Do not comment.
2. **Wake-needed** — assignee is stalled with no live continuation. Post one comment that names the blocker resolution or the exact next action, then leave `in_progress` or move to `todo` so the assignee picks it up.
3. **Reassign** — the assignee is not the right specialty. Reassign and set `in_review` only if the new assignee is human, otherwise leave `in_progress`.
4. **Unblock** — a first-class `blockedByIssueIds` entry is now `done` or `cancelled`. If `cancelled`, replace or remove it from `blockedByIssueIds`. The blockers-resolved wake will fire automatically when all are `done`.
5. **Escalate** — the issue needs board, CTO, or user input. Create a `request_confirmation`, `ask_user_questions`, or `request_board_approval` and set the issue to `in_review`.
6. **Close** — work is complete, duplicate, or no longer relevant. Set `done` or `cancelled` with a one-line reason.
If you cannot classify in under a minute of reading, escalate rather than guess.
## Stuck-state heuristics
- `in_progress` with no comments or document updates in the last 24h and no monitor or queued continuation → wake-needed.
- `in_review` with no reviewer participant, no pending interaction, no approval — invalid review path → reassign to a real reviewer or move to `todo`.
- `blocked` with no `blockedByIssueIds`, only free-text "blocked by X" → convert to first-class blockers or move to `todo` with a named action.
- `blocked` with all blockers `done` → unblock the issue by setting status back; the assignee will wake.
- Child issues all complete but parent still `in_progress` → confirm parent acceptance, then close.
## Don't-do list
- Do not @-mention agents during triage; mentions cost budget. Use direct reassignment instead.
- Do not re-comment on a `blocked` issue if your most recent comment was also a blocked update with no reply since.
- Do not cancel cross-team issues. Reassign to the responsible manager with a comment.
- Do not change status without a comment that explains the change.
## Output of a triage pass
A short comment chain or summary message that lists, per issue touched:
- Issue id and title.
- Verdict (resume / wake-needed / reassign / unblock / escalate / close).
- The one action you took or asked for.
This is the bar for "the triage is done."
@@ -0,0 +1,84 @@
---
name: task-planning
description: Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.
key: paperclipai/bundled/paperclip-operations/task-planning
recommendedForRoles:
- manager
- engineer
- product
tags:
- paperclip
- planning
- issues
- delegation
---
# Task Planning
Produce implementation plans that the Paperclip executor can actually run: explicit child issues, real blockers, named owners, and a defined acceptance bar. Avoid plans that read well but cannot be split into work.
## When to use
- An issue asks you to "plan", "scope", "break down", "design the rollout", "propose the work", or similar.
- A user wants a written plan before approving implementation.
- A manager needs to delegate non-trivial work and the shape of the work is not obvious yet.
- You inherited an issue too large to deliver in one heartbeat and need to split it.
## When not to use
- The issue is a single small change you can ship in the same heartbeat. Just ship it.
- The issue is forensic ("why did this break"). Use a diagnosis skill first; plan only after the root cause is named.
- A current `plan` document already exists and the change is minor. Update that document; do not start fresh.
## Outputs
1. An updated issue document with key `plan` (markdown).
2. A short comment on the issue that links to the plan document and names the next action.
3. Where the plan requires approval, an issue-thread interaction of kind `request_confirmation` bound to the latest plan revision.
Do not create implementation subtasks until the plan is accepted.
## Plan structure
Required sections, in order:
1. **Goal** — one paragraph. What changes for the user, the operator, or the system once this work lands.
2. **Context reviewed** — bullet list of documents, files, and prior issues you read. Lets reviewers spot missing inputs.
3. **Constraints and non-goals** — what must hold (compatibility, security, performance) and what this plan deliberately will not do.
4. **Approach** — the chosen path, with a short rationale. If you considered alternatives, name them and why you rejected them.
5. **Work breakdown** — ordered list of child issues. Each child has:
- Title in imperative form.
- Owner specialty (Engineer, QA, Designer, Security, DevRel, Manager, etc.).
- Scope and deliverables.
- Acceptance criteria.
- Blocks/blocked-by relationships expressed by phase letter or child title.
6. **Acceptance** — the bar for the parent issue. How the user knows the whole thing is done.
7. **Risks and mitigations** — short list. Skip if there are none.
8. **Deferrals** — what is intentionally pushed to follow-up issues, with why.
## Rules of thumb for splitting
- One child issue, one specialty. If two specialties have to coordinate inside the same issue, split it.
- One child issue, one acceptance verdict. If a reviewer would say "this is half done", split it.
- A child must be checkout-able by the owner from its title and description alone. Reviewers should not have to re-read the parent plan to understand a child.
- Order children by real blocker chains, not by author preference. Parallel children should explicitly say `blockers: none`.
- Avoid `polish` or `cleanup` child issues without acceptance criteria — they never close.
## Filing the plan
Use the Paperclip API to write the plan document, then comment:
- `PUT /api/issues/{issueId}/documents/plan` with the markdown body. If `plan` already exists, include the latest `baseRevisionId`.
- `POST /api/issues/{issueId}/comments` with a short summary that links the plan: `/<prefix>/issues/<issue-id>#document-plan`.
- If approval is required: `POST /api/issues/{issueId}/interactions` with `kind: request_confirmation`, `targetRevisionId` set to the new plan revision, `continuationPolicy: wake_assignee`, and `idempotencyKey: "confirmation:{issueId}:plan:{revisionId}"`.
- Set the issue to `in_review` after creating the confirmation. Stay assigned so the acceptance wakes the planner.
When the plan is accepted, see the companion skill for converting accepted plans into Paperclip executable tasks.
## Anti-patterns
- Plan disguised as a description edit. Use the `plan` document.
- "Phases AZ" with no work breakdown inside the phases.
- Children with descriptions that say "see parent" — they fail at delegation time.
- Acceptance written as "code review approval". Reviewers need a behavior bar, not a process bar.
- Plans that bury blocker chains in prose. Use explicit blocked-by lines.
@@ -0,0 +1,93 @@
---
name: qa-acceptance
description: Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.
key: paperclipai/bundled/quality/qa-acceptance
recommendedForRoles:
- qa
- engineer
- product
tags:
- qa
- acceptance
- validation
- testing
---
# QA Acceptance
Write acceptance criteria that a reviewer can run against the running app and decide pass or fail without asking the author. The criteria are the contract — automated tests cover correctness, QA covers feature-level behavior.
## When to use
- A feature change is heading to QA and needs a written validation plan.
- A reviewer is asked to verify a PR that touches user-visible behavior.
- An incident postmortem requires a regression check before reopen-prevention.
- A release candidate needs a pre-cut smoke pass.
## When not to use
- The change is unit-test-only (utility refactor, internal naming). Acceptance criteria are unnecessary churn.
- You are asked to write tests against API contracts. Use contract testing, not feature QA.
## Acceptance criteria format
Each criterion is a single, independently-verifiable statement:
```md
- **Given** <starting state>, **when** <action>, **then** <observable outcome>.
```
Example:
```md
- **Given** a CSV export with 0 rows, **when** the user clicks Export, **then** the file downloads with only the header row and the UI shows "Exported 0 rows".
```
Avoid criteria that combine multiple `when`s or `then`s. Split them.
## What every plan must cover
1. **Golden path.** The most common successful flow, end to end.
2. **Empty and minimum states.** Zero items, one item, missing optional inputs.
3. **Boundary inputs.** Max length strings, max numeric values, unicode, RTL text where applicable.
4. **Error states.** Network failure, permission denied, validation failures, conflict (409), not found (404).
5. **Concurrency and ordering.** Two users acting at once, race against background jobs, refresh during mutation.
6. **Performance envelope.** The largest realistic input the change must handle without UI hangs or timeouts.
7. **Backward compatibility.** Existing data, existing URLs, persisted user preferences continue to work.
8. **Telemetry and audit.** Events, logs, or activity entries the change is supposed to emit.
If a section is genuinely not applicable, write "N/A: <why>" — do not silently omit.
## Evidence
Each criterion needs evidence on the verification pass:
- Screenshot or short clip for UI behavior.
- Copied console / network output for API behavior.
- Log snippet or activity row for telemetry.
- Timing measurement for performance criteria.
"Looks good to me" without evidence is not a pass.
## Quarantine and follow-up
- A failing criterion blocks acceptance unless explicitly waived by the owner with a tracked follow-up issue.
- "Known issue" without a linked follow-up is not a waiver.
- If you add a new criterion mid-pass, restart the pass — partial coverage hides regressions.
## Handoff back to the author
Return the validation plan with three sections:
- **Pass.** Criteria that passed, with one-line evidence summaries.
- **Fail.** Criteria that failed, with the exact reproduction.
- **Blocked.** Criteria you could not run, with why.
The author owns turning failures into either fixes or accepted deferrals.
## Anti-patterns
- Acceptance phrased as test plan ("write a Cypress test for X"). Acceptance is what is true after the change ships; tests are how you check.
- Criteria that depend on inspecting implementation details (selectors, query plans). Stay observable.
- Long checklists with no priority. Mark must-pass criteria distinctly from nice-to-have.
- Validation reports that say "passed" with no evidence. Reviewers cannot audit those.
@@ -0,0 +1,93 @@
---
name: github-pr-workflow
description: Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.
key: paperclipai/bundled/software-development/github-pr-workflow
recommendedForRoles:
- engineer
tags:
- github
- pull-requests
- code-review
- release
---
# GitHub Pull Request Workflow
Ship a PR a reviewer can land without follow-up clarifying questions. The aim is high signal in the title and body, evidence the change works, and clean replies when feedback comes in.
## When to use
- You are about to open a PR for a change that is functionally complete.
- A reviewer left comments and you need to respond and push fixes.
- A PR has been open more than a day and needs to be brought back into shape (stale conflicts, missing description, missing verification).
## When not to use
- The change is not yet functionally complete. Finish the work first; draft PRs that bounce on review are noise.
- The repository uses a non-GitHub forge. Adjust to that forge's conventions; do not force GitHub-isms.
## Branch hygiene before opening
- Rebase or merge from the target base so the diff is current.
- Squash WIP commits into reviewable units. Prefer one commit per logical change; do not force one-commit-per-PR if the work is genuinely multi-step.
- Confirm tests, typecheck, and lint pass locally. Note any deliberate skips in the PR body.
- Remove debug prints, commented-out code, and `TODO` markers that are not tracked.
## PR title
- Imperative mood, under 70 characters.
- Lead with the user-visible change, not the file touched. `Allow CSV export from reports table` beats `Update reports.tsx`.
- If the repo uses an issue prefix convention (`PAP-1234:`, `[security]`), follow it.
- No trailing period.
## PR body
Use this structure:
```md
## Summary
- 13 bullets describing what changed and why.
## Implementation notes
- Anything non-obvious in the diff: trade-offs, dropped alternatives, gotchas.
- Migration or config implications.
## Verification
- The exact commands or steps you ran.
- Screenshots or short clips for UI changes (required if pixels moved).
- Edge cases you exercised by hand.
## Risk and rollback
- What breaks if this is reverted, and how to revert cleanly.
```
Skip the `Risk and rollback` section only for clearly trivial PRs (typos, docs).
## Verification evidence
- Tests passing in CI is necessary, not sufficient. Reviewers also need to know the change behaves correctly end to end.
- For UI work, include screenshots of the golden path and one edge case. Tag dark and light mode if the project supports both.
- For migrations, include a dry-run plan and reversal steps.
- For performance changes, include a before/after measurement, not adjectives.
## Replying to review comments
- Reply on every comment, even with just "fixed in <commit-sha>" — silent fixes leave the reviewer guessing.
- Push fixes as new commits while review is active; do not amend during review unless the reviewer agrees.
- If you disagree with feedback, say so with one sentence of rationale and let the reviewer decide. Don't escalate over comments.
- Re-request review explicitly after pushing changes.
## Merge checklist
- All required checks green.
- All review comments resolved.
- PR title/body still accurate (update if scope changed mid-review).
- Linked issue moves to `in_review` or `done` per project convention.
- Delete the branch after merge unless it is a long-lived integration branch.
## Anti-patterns
- PR description that says "see commits". Reviewers should not need to read the log.
- Mixing refactor and behavior change in the same PR with no separation in the body.
- "Address feedback" commits that bundle unrelated edits. One commit per round of feedback is fine; one commit for everything in flight is not.
- Force-pushing during active review without telling the reviewer.
@@ -0,0 +1,93 @@
---
name: agent-browser
description: Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.
key: paperclipai/optional/browser/agent-browser
recommendedForRoles:
- qa
- engineer
- researcher
tags:
- browser
- puppeteer
- playwright
- verification
---
# Agent Browser
Use a controlled browser to verify behavior, capture evidence, or extract information from web pages that a static fetch cannot reach (SPAs, login-gated pages, dynamic content). This skill is about supervised verification, not unattended scraping.
## When to use
- You need a screenshot of a deployed page or a local dev server to confirm a UI change.
- You need to read JavaScript-rendered content that `curl`/`wget` will not see.
- A user reports a UI bug and you need to reproduce it interactively to capture console errors, network requests, or layout state.
- You need to walk through a short flow (load page, click, observe) to verify acceptance criteria.
## When not to use
- The page is reachable as static HTML. Use `curl`/HTTP fetch — it is cheaper, faster, and more reliable.
- The task is unattended large-scale scraping. That belongs to a dedicated scraper with rate limits, robots.txt handling, and a real user agent policy — not this skill.
- The site is behind authentication you do not own credentials for, or whose terms of service prohibit automation.
- The site involves sensitive accounts (banking, healthcare, government) where automation risks lockout or compliance issues.
## Before launching the browser
- Confirm the URL and what state should be true after navigation.
- Decide what evidence is needed: full-page screenshot, viewport screenshot, console log, network trace, HTML snapshot, extracted text.
- Decide the viewport size that matters for the task (mobile vs desktop). Default to a desktop size unless the task is mobile-specific.
- For local dev servers, confirm the server is running and the port is what you expect.
## Driving the browser
A typical verification session:
1. **Launch with a real-looking user agent** when the target is the public internet; an unrealistic UA flags automation traffic.
2. **Set a sane viewport** (e.g., 1366×768 desktop, 390×844 iPhone-ish).
3. **Navigate and wait for the right signal.** Prefer waiting for a specific selector or network-idle over arbitrary sleeps.
4. **Capture evidence immediately** after the wait condition succeeds, before any interaction perturbs the state.
5. **Interact deliberately.** One click at a time, with a wait between actions; re-screenshot after each meaningful state change.
6. **Read the console and network panels** for unexpected errors, 4xx/5xx responses, or slow requests.
7. **Close the browser cleanly** when done. Long-running browser sessions leak memory and hold ports.
## What evidence to record
For a verification task, deliver:
- A full-page or viewport screenshot of each meaningful state.
- The console log, filtered to warnings/errors.
- Any non-2xx network response with the URL, status, and a short response body excerpt.
- A short narration: "Navigated to X, observed Y, clicked Z, observed W."
For a UI bug repro, also record:
- The exact reproduction steps the user can follow.
- Viewport size and (where relevant) device pixel ratio.
- Whether the bug reproduces on first load vs after interaction.
## Login-gated pages
- Prefer programmatic auth (API token, magic link) over UI login.
- If UI login is the only path, the user must provide credentials explicitly for this run. Never reuse credentials outside the session.
- Do not store credentials in the session log, screenshot, or returned output.
## Performance and politeness
- Throttle to one navigation per few seconds when touching shared infra.
- Respect `robots.txt` for public sites you are inspecting at any volume.
- Cancel navigations if a page exceeds a reasonable timeout (e.g., 30s); the page is broken or rate-limiting you.
- Do not retry forever on failure. Retry once with a longer timeout, then escalate.
## Common failure modes
- **Selector not found.** Page changed, or you are waiting before render. Take a screenshot to see actual state; adjust the selector.
- **Click does nothing.** The element is offscreen, covered by a modal, or in a shadow DOM. Scroll into view or pierce the shadow root.
- **Headless detection.** Some sites detect headless Chrome and serve a different page. Use a non-headless mode or a fingerprint-realistic configuration only when authorized.
- **Cross-origin iframe blocking.** Iframes you do not own cannot be inspected; the page must offer the data outside the iframe or the task is infeasible.
## Anti-patterns
- Long unsupervised browser sessions that drift from the original task.
- Scraping behind authentication you do not own.
- Captioning a screenshot with "looks good" without saying what state was loaded and what selectors confirmed it.
- Treating a passing screenshot as proof of correctness across viewports you did not actually test.
@@ -0,0 +1,128 @@
---
name: release-announcement
description: Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.
key: paperclipai/optional/content/release-announcement
recommendedForRoles:
- devrel
- product
- writer
tags:
- release
- changelog
- announcement
- communication
---
# Release Announcement
Write the channel-appropriate announcement for a release without churn. Different surfaces need different shapes: a changelog entry is not a blog post is not a social card. The bar is: a reader of the chosen surface can decide in under 30 seconds whether this release affects them, and if so what to do.
## When to use
- A version, feature, or fix is shipping and needs writeup for at least one surface.
- A previously private feature is going GA.
- A breaking change needs broadcast before users hit it.
## When not to use
- An internal-only change with no user impact. Update internal docs; do not announce.
- The release is incomplete (still in active development). Wait until it ships, even if marketing wants the post.
## Determine the audience and channel first
| Audience | Best channel | Tone |
|---|---|---|
| Existing power users | Changelog, in-app note | Terse, factual, links |
| Engineering teams adopting your API | Release notes, dev blog | Examples, migration steps, version pins |
| Prospective customers | Landing page, marketing blog | Story arc, problem → solution, social proof |
| Broad audience | Social post, email newsletter | One-sentence pitch, link to depth |
| Internal team | Slack/Discord post | What changed, who to ping if it breaks |
Pick the audience for *this* writeup. One release often needs several writeups; do not blend them.
## Universal structure
Whatever the channel, lead with:
1. **What changed.** One sentence in the user's vocabulary.
2. **Who it affects.** Which user role / use case.
3. **What to do.** Migrate now / opt-in / no action needed.
Everything else is depth that supports those three.
## Channel templates
### Changelog entry (terse)
```md
## v1.42.0 — 2026-05-26
### Added
- <feature> — <one-line user benefit>. ([#1234](link))
### Changed
- <change> — <one-line impact>. ([#1235](link))
### Fixed
- <bug> — <one-line user-visible symptom>. ([#1236](link))
### Deprecated
- <thing>. Replaced by <thing>. Removal planned for v<x>.
### Breaking
- <change>. **Migration:** <one-line> or <link to guide>.
```
### Release notes (for adopters)
Same as changelog, plus:
- Migration guide section with before/after code.
- Compatibility table (versions, runtimes, OS).
- Known issues and workarounds.
- Acknowledgements (contributors, reporters of fixed bugs).
### Dev blog post (300800 words)
- **Hook (1 paragraph):** the problem the release solves, in a real-world scenario.
- **What's new (35 bullets with sub-paragraphs):** features, with one code or screenshot example each.
- **Upgrade (1 paragraph):** how to upgrade, what to check.
- **What's next:** one sentence about the next direction. Avoid promises.
### In-app note
- 1 sentence.
- 1 link.
- Dismiss after seen.
### Social post
- 1 sentence pitch.
- 1 link.
- 1 image or short clip.
- No threadbait. If it needs a thread, write a blog post instead.
## Writing rules
- Lead with the user, not the team. `You can now export to CSV` beats `We've added CSV export`.
- Numbers beat adjectives. `60% faster cold start` beats `much faster`. Cite the methodology.
- Show, don't just tell. One code snippet, one screenshot — more is noise.
- Date the post. Undated release content rots fastest.
- Link the migration path explicitly. Do not bury it.
- Mark breaking changes with `**Breaking:**` prefix. Repeat in the email/social channel.
## Avoid
- "We are excited to announce" filler.
- Lists of changes that mix user-visible and internal items.
- Marketing claims without a way to verify.
- Promised dates for unshipped work.
- Pre-announcing something the team has not yet committed to ship.
## Post-publish checklist
- Changelog is in source control alongside the release.
- Blog post date matches actual ship date.
- All links work (release tag, PRs, docs sections).
- Breaking changes are also in the upgrade guide, not only the post.
- Internal team is notified before the public post goes live, not after.
@@ -0,0 +1,121 @@
---
name: design-critique
description: Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.
key: paperclipai/optional/product/design-critique
recommendedForRoles:
- designer
- product
- engineer
tags:
- design
- product
- ux
- review
---
# Product Design Critique
A structured critique pass for a screen, flow, or component. The output is a prioritized list of changes a designer or engineer can act on — not adjectives. Critique is not redesign; recommend, do not rebuild.
## When to use
- A designer or engineer asks for feedback on a screen, mock, or live UI.
- A feature is shipping and someone wants a final UX read.
- A flow is suspected of causing user drop-off and you want a pre-research read before instrumentation.
## When not to use
- The user wants a redesign. That is a design project, not a critique.
- The work is so early that no concrete artifact exists. Sketch with them instead of critiquing air.
- You have no context on the user job. Ask for it first; design critique without user context devolves into taste.
## Pre-critique context
Before opening a screen, get:
- **Who is the user.** Specific role and competence, not "users".
- **What job they are doing on this screen.** One sentence.
- **What success looks like.** What the user can do after this screen that they could not before.
- **Where this screen sits in the larger flow.** What precedes and follows.
If any of these is missing, ask. Critique without these is opinion.
## The pass (in order)
1. **Clarity of the user job.**
- Within 3 seconds of opening, is it obvious what this screen is for?
- Does the primary action match the user's actual job, or a designer's preferred path?
2. **Visual hierarchy.**
- The most important thing on the screen should be the most prominent (size, weight, position, color).
- Secondary actions should look secondary. Tertiary should be findable but not loud.
- Headings should chunk content into the right groups for the task.
3. **Affordance and signifiers.**
- Clickable things look clickable.
- Disabled things look disabled and explain why on hover/focus.
- Drag, scroll, or swipe interactions are discoverable, not hidden.
4. **States.**
- Empty state (no data) is designed, not a blank rectangle.
- Loading state communicates progress, not just spins.
- Error states say what went wrong and what to do next, in the user's words.
- Success state confirms without celebrating banal actions.
5. **Inputs and forms.**
- Labels visible, not just placeholders.
- Validation runs at the right time (on blur, not on every keystroke unless the user is in a known-format field).
- Required fields marked.
- Field order matches the user's mental order, not the database order.
6. **Accessibility.**
- Sufficient color contrast (WCAG AA at minimum; AAA where reasonable).
- Focus order is logical for keyboard navigation.
- Interactive elements are reachable without a mouse.
- Critical information is not color-only (icons, text, position back it up).
- Touch targets at least 44×44 px on mobile.
7. **Consistency.**
- Tokens, components, and patterns match the rest of the product.
- "Borrowed" patterns from other products are intentional, not accidental drift.
8. **Copy.**
- Buttons are verbs that name the outcome ("Save changes" beats "Submit").
- Microcopy explains, does not decorate.
- Tone matches the product voice.
9. **Edge cases.**
- Long content (long names, many items, RTL languages).
- Tiny content (one item, zero items).
- Slow network and offline behavior.
- Permissions denied.
## Output format
Group findings by severity, then by category. Each finding is one issue and one suggested fix.
```md
## Design critique: <screen name>
### Must-fix (blocks ship)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Should-fix (before broader rollout)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Nice-to-fix (when there's room)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Strengths to keep
- <one-line thing the design got right>
```
Always include the "strengths to keep" section. It is not flattery — it is signal to the designer about what not to change in the next round.
## Anti-patterns
- "I would do it differently" without saying what or why. That is preference, not critique.
- Long critiques that bury must-fix items under nice-to-haves.
- Suggesting net-new features under the guise of a critique.
- Ignoring user context and grading on taste.
- Treating a critique as approval. State approval explicitly if asked; otherwise critique is feedback, not sign-off.
@@ -0,0 +1,285 @@
{
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
"generatedAt": "2026-05-28T03:02:49.579Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
"key": "paperclipai/bundled/docs/doc-maintenance",
"kind": "bundled",
"category": "docs",
"slug": "doc-maintenance",
"name": "doc-maintenance",
"description": "Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.",
"path": "catalog/bundled/docs/doc-maintenance",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"engineer",
"product",
"devrel"
],
"requires": [],
"tags": [
"docs",
"documentation",
"release-notes"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4478,
"sha256": "fb0353386c5e5e5e13bcbb3233f044e3dccecf371f429d6328f26c26d7cb6169"
}
],
"contentHash": "sha256:2e02299210fd17c1fe1867b4ee8c144a11b6fe1fe481f83b8268cfbaaf10f9aa"
},
{
"id": "paperclipai:bundled:paperclip-operations:issue-triage",
"key": "paperclipai/bundled/paperclip-operations/issue-triage",
"kind": "bundled",
"category": "paperclip-operations",
"slug": "issue-triage",
"name": "issue-triage",
"description": "Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).",
"path": "catalog/bundled/paperclip-operations/issue-triage",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"manager",
"ceo",
"engineer"
],
"requires": [],
"tags": [
"paperclip",
"triage",
"inbox",
"workflow"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4042,
"sha256": "df5bdc8bf5e017b7ba5f70a4b5323fad51d0c323278f386580f26cf43ad09160"
}
],
"contentHash": "sha256:88dc13560371fb364963782cb4f6eeb4090fcde92ee3774479428ed6b90e11c1"
},
{
"id": "paperclipai:bundled:paperclip-operations:task-planning",
"key": "paperclipai/bundled/paperclip-operations/task-planning",
"kind": "bundled",
"category": "paperclip-operations",
"slug": "task-planning",
"name": "task-planning",
"description": "Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.",
"path": "catalog/bundled/paperclip-operations/task-planning",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"manager",
"engineer",
"product"
],
"requires": [],
"tags": [
"paperclip",
"planning",
"issues",
"delegation"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4649,
"sha256": "2ff61e12dfaa4cf8cc548529fd176f55f1b1f5292ff9dd3eb2cb331417ab5e4e"
}
],
"contentHash": "sha256:4fb46a4bcefad4fd46fae48c433ee497112509a8e19fb8a7745ead44d219b498"
},
{
"id": "paperclipai:bundled:quality:qa-acceptance",
"key": "paperclipai/bundled/quality/qa-acceptance",
"kind": "bundled",
"category": "quality",
"slug": "qa-acceptance",
"name": "qa-acceptance",
"description": "Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.",
"path": "catalog/bundled/quality/qa-acceptance",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"qa",
"engineer",
"product"
],
"requires": [],
"tags": [
"qa",
"acceptance",
"validation",
"testing"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 3861,
"sha256": "c631b437ab26d104af6cdb963d8f679a9341439041b3cb3ec8835f4ff551b378"
}
],
"contentHash": "sha256:32372dacaf62e93454b9855968c4eec96456ba78b509f450b3dfaa48e31ef356"
},
{
"id": "paperclipai:bundled:software-development:github-pr-workflow",
"key": "paperclipai/bundled/software-development/github-pr-workflow",
"kind": "bundled",
"category": "software-development",
"slug": "github-pr-workflow",
"name": "github-pr-workflow",
"description": "Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.",
"path": "catalog/bundled/software-development/github-pr-workflow",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"engineer"
],
"requires": [],
"tags": [
"github",
"pull-requests",
"code-review",
"release"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 3970,
"sha256": "f498ec4ebb1779dea37adeb1db8a8b22316282798e35ee02e2fc5ff627d7e261"
}
],
"contentHash": "sha256:90f278c89aa0711be150c1cd2456ca25620d02f36995b113ca9837d756a37f6c"
},
{
"id": "paperclipai:optional:browser:agent-browser",
"key": "paperclipai/optional/browser/agent-browser",
"kind": "optional",
"category": "browser",
"slug": "agent-browser",
"name": "agent-browser",
"description": "Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.",
"path": "catalog/optional/browser/agent-browser",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"qa",
"engineer",
"researcher"
],
"requires": [],
"tags": [
"browser",
"puppeteer",
"playwright",
"verification"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 5133,
"sha256": "362f7b9d02297782bc6f0c093f495b8a0304a75bcf4b42e5c280a42b1f757b7d"
}
],
"contentHash": "sha256:eabb2c9f7b5e1a27ebb1e05a711d61433a266478154cd671a685e99e67aadea2"
},
{
"id": "paperclipai:optional:content:release-announcement",
"key": "paperclipai/optional/content/release-announcement",
"kind": "optional",
"category": "content",
"slug": "release-announcement",
"name": "release-announcement",
"description": "Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.",
"path": "catalog/optional/content/release-announcement",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"devrel",
"product",
"writer"
],
"requires": [],
"tags": [
"release",
"changelog",
"announcement",
"communication"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4416,
"sha256": "062810ac34e9edc89efa701fec2eee60f16949d1944cc2cae49803cb91e8cbf4"
}
],
"contentHash": "sha256:f22a9ed696e6614c6db2757a149f48b3295e81f78c27d065d9cb164cf4f8a9bd"
},
{
"id": "paperclipai:optional:product:design-critique",
"key": "paperclipai/optional/product/design-critique",
"kind": "optional",
"category": "product",
"slug": "design-critique",
"name": "design-critique",
"description": "Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.",
"path": "catalog/optional/product/design-critique",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"designer",
"product",
"engineer"
],
"requires": [],
"tags": [
"design",
"product",
"ux",
"review"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4851,
"sha256": "022e619baf6cc25725946279cb8052d22af090dd6cd6dc8c20f17867f71a5d8e"
}
],
"contentHash": "sha256:429f94df398a0697042b5bbe4755b1ff1a230aa5f41d99118ad37493ac65d21c"
}
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@paperclipai/skills-catalog",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/skills-catalog"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts",
"./catalog.json": "./generated/catalog.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./types": {
"types": "./dist/src/types.d.ts",
"import": "./dist/src/types.js"
},
"./catalog.json": "./dist/generated/catalog.json"
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"files": [
"catalog",
"dist",
"generated"
],
"scripts": {
"build": "pnpm run build:manifest && tsc -p tsconfig.json",
"build:manifest": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/build-catalog-manifest.ts",
"clean": "rm -rf dist",
"test": "pnpm -w exec vitest run --root packages/skills-catalog --config vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts"
}
}
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { writeCatalogManifest } from "../src/catalog-builder.js";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await writeCatalogManifest(packageDir);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`- ${error}`);
}
process.exitCode = 1;
} else {
console.log(`Wrote generated/catalog.json with ${result.manifest.skills.length} catalog skills.`);
}
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { validateCatalog } from "../src/catalog-builder.js";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await validateCatalog(packageDir);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`- ${error}`);
}
process.exitCode = 1;
} else {
console.log(`Catalog manifest is valid with ${result.manifest.skills.length} catalog skills.`);
}
@@ -0,0 +1,165 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCatalogManifest,
formatCatalogManifest,
validateCatalog,
} from "./catalog-builder.js";
const tempDirs: string[] = [];
describe("skills catalog manifest", () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it("builds stable manifest entries from catalog skill directories", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "software-development", "github-pr-workflow", {
frontmatter: [
"name: GitHub PR Workflow",
"description: Prepare pull requests and verification notes.",
"key: paperclipai/bundled/software-development/github-pr-workflow",
"recommendedForRoles:",
" - engineer",
"tags:",
" - github",
" - pull-requests",
],
files: {
"references/checklist.md": "# Checklist\n",
},
});
const result = await buildCatalogManifest({
packageDir,
generatedAt: "2026-05-26T00:00:00.000Z",
});
expect(result.errors).toEqual([]);
expect(result.manifest.skills).toHaveLength(1);
expect(result.manifest.skills[0]).toMatchObject({
id: "paperclipai:bundled:software-development:github-pr-workflow",
key: "paperclipai/bundled/software-development/github-pr-workflow",
kind: "bundled",
category: "software-development",
slug: "github-pr-workflow",
name: "GitHub PR Workflow",
trustLevel: "markdown_only",
compatibility: "compatible",
recommendedForRoles: ["engineer"],
tags: ["github", "pull-requests"],
});
expect(result.manifest.skills[0]!.files.map((file) => file.path)).toEqual([
"SKILL.md",
"references/checklist.md",
]);
expect(result.manifest.skills[0]!.contentHash).toMatch(/^sha256:[a-f0-9]{64}$/);
});
it("reports frontmatter, directory, uniqueness, and inventory errors together", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "Bad_Category", "duplicate", {
frontmatter: [
"name: Duplicate",
"key: paperclipai/bundled/software-development/other",
"recommendedForRoles: engineer",
],
});
await writeSkill(packageDir, "optional", "software-development", "duplicate", {
frontmatter: [
"name: Duplicate Optional",
"description: Optional duplicate slug.",
],
});
await fs.mkdir(path.join(packageDir, "catalog", "bundled", "software-development", "missing-skill"), {
recursive: true,
});
await fs.mkdir(path.join(packageDir, "catalog", "misc"), { recursive: true });
await fs.writeFile(path.join(packageDir, "catalog", "misc", "SKILL.md"), "# Misplaced\n", "utf8");
const result = await buildCatalogManifest({
packageDir,
generatedAt: "2026-05-26T00:00:00.000Z",
});
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining("catalog/misc/SKILL.md is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md"),
expect.stringContaining("catalog/bundled/software-development/missing-skill is missing SKILL.md"),
expect.stringContaining("has invalid category"),
expect.stringContaining("frontmatter must include description"),
expect.stringContaining("key must be paperclipai/bundled/Bad_Category/duplicate"),
expect.stringContaining("field recommendedForRoles must be an array of strings"),
expect.stringContaining("Duplicate catalog slug \"duplicate\""),
]),
);
});
it("detects stale generated manifests", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "software-development", "review", {
frontmatter: [
"name: Review",
"description: Review implementation work.",
],
});
await fs.mkdir(path.join(packageDir, "generated"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "generated", "catalog.json"),
formatCatalogManifest({
schemaVersion: 1,
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
generatedAt: "2026-05-26T00:00:00.000Z",
skills: [],
}),
"utf8",
);
const result = await validateCatalog(packageDir);
expect(result.errors).toContain(
"generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.",
);
});
});
async function createCatalogPackage() {
const packageDir = await fs.mkdtemp(path.join(os.tmpdir(), "skills-catalog-"));
tempDirs.push(packageDir);
await fs.mkdir(path.join(packageDir, "catalog", "bundled"), { recursive: true });
await fs.mkdir(path.join(packageDir, "catalog", "optional"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({ version: "0.3.1" }),
"utf8",
);
return packageDir;
}
async function writeSkill(
packageDir: string,
kind: "bundled" | "optional",
category: string,
slug: string,
options: {
frontmatter: string[];
files?: Record<string, string>;
},
) {
const skillDir = path.join(packageDir, "catalog", kind, category, slug);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---\n${options.frontmatter.join("\n")}\n---\n\nUse this skill.\n`,
"utf8",
);
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.join(skillDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
}
@@ -0,0 +1,443 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
asBoolean,
asString,
asStringArray,
parseFrontmatterMarkdown,
} from "./frontmatter.js";
import type {
CatalogManifest,
CatalogSkill,
CatalogSkillFile,
CatalogSkillFileKind,
CatalogSkillKind,
CatalogTrustLevel,
} from "./types.js";
const CATALOG_PACKAGE_NAME = "@paperclipai/skills-catalog";
const CATALOG_SCHEMA_VERSION = 1;
const SKILL_ENTRYPOINT = "SKILL.md";
const MAX_CATALOG_FILE_BYTES = 1024 * 1024;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const CATALOG_KINDS = new Set<CatalogSkillKind>(["bundled", "optional"]);
interface SkillCandidate {
kind: CatalogSkillKind;
category: string;
slug: string;
absolutePath: string;
}
interface BuildCatalogManifestOptions {
packageDir: string;
generatedAt?: string;
}
interface BuildCatalogManifestResult {
manifest: CatalogManifest;
errors: string[];
}
export function formatCatalogManifest(manifest: CatalogManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export async function buildExpectedCatalogManifest(
packageDir: string,
): Promise<BuildCatalogManifestResult> {
const existing = await readExistingManifest(packageDir);
const firstPass = await buildCatalogManifest({
packageDir,
generatedAt: existing?.generatedAt ?? new Date().toISOString(),
});
if (existing && sameManifestExceptGeneratedAt(existing, firstPass.manifest)) {
return firstPass;
}
return buildCatalogManifest({
packageDir,
generatedAt: new Date().toISOString(),
});
}
export async function buildCatalogManifest(
options: BuildCatalogManifestOptions,
): Promise<BuildCatalogManifestResult> {
const packageDir = path.resolve(options.packageDir);
const packageJson = await readPackageJson(packageDir);
const errors: string[] = [];
const candidates = await discoverSkillCandidates(packageDir, errors);
const skills: CatalogSkill[] = [];
collectCandidateUniquenessErrors(candidates, errors);
for (const candidate of candidates) {
const skill = await buildCatalogSkill(packageDir, candidate, errors);
if (skill) skills.push(skill);
}
skills.sort((a, b) => a.id.localeCompare(b.id));
collectUniquenessErrors(skills, errors);
return {
manifest: {
schemaVersion: CATALOG_SCHEMA_VERSION,
packageName: CATALOG_PACKAGE_NAME,
packageVersion: packageJson.version,
generatedAt: options.generatedAt ?? new Date().toISOString(),
skills,
},
errors,
};
}
export async function validateCatalog(packageDir: string): Promise<BuildCatalogManifestResult> {
const expected = await buildExpectedCatalogManifest(packageDir);
const generatedPath = path.join(packageDir, "generated", "catalog.json");
const errors = [...expected.errors];
let generatedText: string | null = null;
try {
generatedText = await fs.readFile(generatedPath, "utf8");
JSON.parse(generatedText);
} catch (error) {
errors.push(`generated/catalog.json is missing or invalid: ${errorMessage(error)}`);
}
if (generatedText !== null) {
const expectedText = formatCatalogManifest(expected.manifest);
if (generatedText !== expectedText) {
errors.push("generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.");
}
}
return {
manifest: expected.manifest,
errors,
};
}
export async function writeCatalogManifest(packageDir: string) {
const result = await buildExpectedCatalogManifest(packageDir);
if (result.errors.length > 0) return result;
const generatedDir = path.join(packageDir, "generated");
await fs.mkdir(generatedDir, { recursive: true });
await fs.writeFile(path.join(generatedDir, "catalog.json"), formatCatalogManifest(result.manifest), "utf8");
return result;
}
async function readPackageJson(packageDir: string) {
const packageJsonPath = path.join(packageDir, "package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { version?: unknown };
const version = asString(packageJson.version);
if (!version) throw new Error(`${packageJsonPath} must declare a package version.`);
return { version };
}
async function readExistingManifest(packageDir: string): Promise<CatalogManifest | null> {
try {
return JSON.parse(await fs.readFile(path.join(packageDir, "generated", "catalog.json"), "utf8")) as CatalogManifest;
} catch {
return null;
}
}
async function discoverSkillCandidates(packageDir: string, errors: string[]) {
const catalogDir = path.join(packageDir, "catalog");
const candidates: SkillCandidate[] = [];
if (!existsSync(catalogDir)) {
errors.push("catalog directory is missing.");
return candidates;
}
await collectMisplacedSkillFiles(catalogDir, errors);
for (const kind of ["bundled", "optional"] as const) {
const kindDir = path.join(catalogDir, kind);
if (!existsSync(kindDir)) continue;
for (const categoryEntry of await sortedDirEntries(kindDir)) {
if (!categoryEntry.isDirectory()) continue;
const category = categoryEntry.name;
const categoryDir = path.join(kindDir, category);
for (const slugEntry of await sortedDirEntries(categoryDir)) {
if (!slugEntry.isDirectory()) continue;
const slug = slugEntry.name;
const skillDir = path.join(categoryDir, slug);
if (!existsSync(path.join(skillDir, SKILL_ENTRYPOINT))) {
errors.push(`${relativePackagePath(packageDir, skillDir)} is missing SKILL.md.`);
continue;
}
candidates.push({ kind, category, slug, absolutePath: skillDir });
}
}
}
return candidates;
}
async function collectMisplacedSkillFiles(catalogDir: string, errors: string[]) {
async function visit(dir: string) {
for (const entry of await sortedDirEntries(dir)) {
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await visit(absolutePath);
continue;
}
if (entry.name !== SKILL_ENTRYPOINT) continue;
const relativePath = toPosixPath(path.relative(catalogDir, absolutePath));
const parts = relativePath.split("/");
const kind = parts[0];
if (parts.length !== 4 || !CATALOG_KINDS.has(kind as CatalogSkillKind)) {
errors.push(`catalog/${relativePath} is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md.`);
}
}
}
await visit(catalogDir);
}
async function buildCatalogSkill(
packageDir: string,
candidate: SkillCandidate,
errors: string[],
): Promise<CatalogSkill | null> {
const prefix = relativePackagePath(packageDir, candidate.absolutePath);
validateSlug("category", candidate.category, prefix, errors);
validateSlug("slug", candidate.slug, prefix, errors);
const id = `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`;
const key = `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`;
const skillMarkdownPath = path.join(candidate.absolutePath, SKILL_ENTRYPOINT);
const parsed = parseFrontmatterMarkdown(await fs.readFile(skillMarkdownPath, "utf8"));
if (!parsed.hasFrontmatter) {
errors.push(`${prefix}/SKILL.md must start with YAML frontmatter.`);
}
const name = asString(parsed.frontmatter.name);
if (!name) errors.push(`${prefix}/SKILL.md frontmatter must include name.`);
const description = asString(parsed.frontmatter.description);
if (!description) errors.push(`${prefix}/SKILL.md frontmatter must include description.`);
const explicitKey = asString(parsed.frontmatter.key);
if (explicitKey && explicitKey !== key) {
errors.push(`${prefix}/SKILL.md key must be ${key}.`);
}
const explicitSlug = asString(parsed.frontmatter.slug);
if (explicitSlug && explicitSlug !== candidate.slug) {
errors.push(`${prefix}/SKILL.md slug must be ${candidate.slug}.`);
}
const defaultInstall = asBoolean(parsed.frontmatter.defaultInstall) ?? false;
const recommendedForRoles = readStringArrayField(parsed.frontmatter.recommendedForRoles, "recommendedForRoles", prefix, errors);
const requires = readStringArrayField(parsed.frontmatter.requires, "requires", prefix, errors);
const tags = readStringArrayField(parsed.frontmatter.tags, "tags", prefix, errors);
const files = await collectSkillFiles(packageDir, candidate.absolutePath, prefix, errors);
if (!name || !description) return null;
return {
id,
key,
kind: candidate.kind,
category: candidate.category,
slug: candidate.slug,
name,
description,
path: toPosixPath(path.relative(packageDir, candidate.absolutePath)),
entrypoint: SKILL_ENTRYPOINT,
trustLevel: deriveTrustLevel(files),
compatibility: "compatible",
defaultInstall,
recommendedForRoles,
requires,
tags,
files,
contentHash: buildContentHash(files),
};
}
async function collectSkillFiles(
packageDir: string,
skillDir: string,
prefix: string,
errors: string[],
): Promise<CatalogSkillFile[]> {
const files: CatalogSkillFile[] = [];
const skillRoot = await fs.realpath(skillDir);
async function visit(dir: string) {
for (const entry of await sortedDirEntries(dir)) {
const absolutePath = path.join(dir, entry.name);
const lstat = await fs.lstat(absolutePath);
let stat = lstat;
let realPath = absolutePath;
if (lstat.isSymbolicLink()) {
try {
realPath = await fs.realpath(absolutePath);
stat = await fs.stat(absolutePath);
} catch {
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a broken symlink.`);
continue;
}
if (!isPathInside(skillRoot, realPath)) {
errors.push(`${relativePackagePath(packageDir, absolutePath)} points outside its skill directory.`);
continue;
}
if (stat.isDirectory()) {
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a directory symlink; copy files into the skill directory instead.`);
continue;
}
}
if (stat.isDirectory()) {
await visit(absolutePath);
continue;
}
if (!stat.isFile()) continue;
const relativePath = toPosixPath(path.relative(skillDir, absolutePath));
if (path.isAbsolute(relativePath) || relativePath.split("/").includes("..")) {
errors.push(`${prefix}/${relativePath} has an invalid inventory path.`);
continue;
}
if (stat.size > MAX_CATALOG_FILE_BYTES) {
errors.push(`${prefix}/${relativePath} exceeds ${MAX_CATALOG_FILE_BYTES} bytes.`);
}
const contents = await fs.readFile(absolutePath);
files.push({
path: relativePath,
kind: classifyCatalogFile(relativePath),
sizeBytes: stat.size,
sha256: sha256(contents),
});
}
}
await visit(skillDir);
files.sort((a, b) => {
if (a.path === SKILL_ENTRYPOINT) return -1;
if (b.path === SKILL_ENTRYPOINT) return 1;
return a.path.localeCompare(b.path);
});
if (!files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill")) {
errors.push(`${prefix} inventory does not contain SKILL.md.`);
}
return files;
}
function readStringArrayField(
value: unknown,
field: string,
prefix: string,
errors: string[],
) {
const parsed = asStringArray(value);
if (!parsed) {
errors.push(`${prefix}/SKILL.md frontmatter field ${field} must be an array of strings.`);
return [];
}
return parsed;
}
function classifyCatalogFile(relativePath: string): CatalogSkillFileKind {
if (relativePath === SKILL_ENTRYPOINT) return "skill";
if (relativePath.startsWith("references/")) return "reference";
if (relativePath.startsWith("scripts/")) return "script";
if (relativePath.startsWith("assets/")) return "asset";
if (relativePath.endsWith(".md") || relativePath.endsWith(".mdx")) return "markdown";
return "other";
}
function deriveTrustLevel(files: CatalogSkillFile[]): CatalogTrustLevel {
if (files.some((file) => file.kind === "script")) return "scripts_executables";
if (files.some((file) => file.kind === "asset" || file.kind === "other")) return "assets";
return "markdown_only";
}
function buildContentHash(files: CatalogSkillFile[]) {
const hashInput = files.map((file) => ({
path: file.path,
sha256: file.sha256,
}));
return `sha256:${sha256(Buffer.from(JSON.stringify(hashInput)))}`;
}
function collectUniquenessErrors(skills: CatalogSkill[], errors: string[]) {
collectDuplicateErrors(skills, "id", errors);
collectDuplicateErrors(skills, "key", errors);
collectDuplicateErrors(skills, "slug", errors);
}
function collectCandidateUniquenessErrors(candidates: SkillCandidate[], errors: string[]) {
const projected = candidates.map((candidate) => ({
id: `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`,
key: `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`,
slug: candidate.slug,
path: toPosixPath(path.join("catalog", candidate.kind, candidate.category, candidate.slug)),
})) as CatalogSkill[];
collectUniquenessErrors(projected, errors);
}
function collectDuplicateErrors(fieldSkills: CatalogSkill[], field: "id" | "key" | "slug", errors: string[]) {
const seen = new Map<string, string>();
for (const skill of fieldSkills) {
const value = skill[field];
const first = seen.get(value);
if (first) {
errors.push(`Duplicate catalog ${field} "${value}" in ${first} and ${skill.path}.`);
continue;
}
seen.set(value, skill.path);
}
}
function validateSlug(label: string, value: string, prefix: string, errors: string[]) {
if (!SLUG_PATTERN.test(value)) {
errors.push(`${prefix} has invalid ${label} "${value}"; use lowercase URL slugs.`);
}
}
async function sortedDirEntries(dir: string) {
return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
}
function sameManifestExceptGeneratedAt(a: CatalogManifest, b: CatalogManifest) {
return JSON.stringify({ ...a, generatedAt: "" }) === JSON.stringify({ ...b, generatedAt: "" });
}
function sha256(contents: Buffer) {
return createHash("sha256").update(contents).digest("hex");
}
function relativePackagePath(packageDir: string, absolutePath: string) {
return toPosixPath(path.relative(packageDir, absolutePath));
}
function toPosixPath(input: string) {
return input.split(path.sep).join("/");
}
function isPathInside(parent: string, child: string) {
const relativePath = path.relative(parent, child);
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+154
View File
@@ -0,0 +1,154 @@
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function asBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function asStringArray(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const item of value) {
const text = asString(item);
if (!text) return null;
out.push(text);
}
return out;
}
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const frontmatterRaw = normalized.slice(4, closing).trim();
const body = normalized.slice(closing + 5).trim();
return {
frontmatter: parseYamlFrontmatter(frontmatterRaw),
body,
hasFrontmatter: true,
};
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const prepared = prepareYamlLines(raw);
if (prepared.length === 0) return {};
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function prepareYamlLines(raw: string) {
return raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
content: line.trim(),
}))
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
}
function parseYamlBlock(
lines: Array<{ indent: number; content: string }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
continue;
}
record[key] = parseYamlScalar(remainder);
}
return { value: record, nextIndex: index };
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (
trimmed.startsWith("\"") ||
trimmed.startsWith("[") ||
trimmed.startsWith("{")
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
+37
View File
@@ -0,0 +1,37 @@
import catalogManifestJson from "../generated/catalog.json" with { type: "json" };
import type { CatalogManifest, CatalogSkill } from "./types.js";
export type {
CatalogCompatibility,
CatalogManifest,
CatalogSkill,
CatalogSkillFile,
CatalogSkillFileKind,
CatalogSkillKind,
CatalogTrustLevel,
CatalogValidationResult,
} from "./types.js";
export const catalogManifest = catalogManifestJson as CatalogManifest;
export const catalogSkills: CatalogSkill[] = catalogManifest.skills;
const skillsById = new Map(catalogSkills.map((skill) => [skill.id, skill]));
const skillsByKey = new Map(catalogSkills.map((skill) => [skill.key, skill]));
export function getCatalogSkill(id: string): CatalogSkill | null {
return skillsById.get(id) ?? null;
}
export function resolveCatalogSkillRef(ref: string): CatalogSkill | null {
const normalized = ref.trim();
if (normalized.length === 0) return null;
const exactMatch = skillsById.get(normalized) ?? skillsByKey.get(normalized);
if (exactMatch) return exactMatch;
const slugMatches = catalogSkills.filter((skill) => skill.slug === normalized);
if (slugMatches.length === 1) return slugMatches[0]!;
return null;
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.js";
import type { CatalogSkill } from "./types.js";
const EXPECTED_BUNDLED_KEYS = [
"paperclipai/bundled/docs/doc-maintenance",
"paperclipai/bundled/paperclip-operations/issue-triage",
"paperclipai/bundled/paperclip-operations/task-planning",
"paperclipai/bundled/quality/qa-acceptance",
"paperclipai/bundled/software-development/github-pr-workflow",
];
const EXPECTED_OPTIONAL_KEYS = [
"paperclipai/optional/browser/agent-browser",
"paperclipai/optional/content/release-announcement",
"paperclipai/optional/product/design-critique",
];
describe("shipped skills catalog", () => {
it("ships the expected bundled and optional skill set", () => {
const bundledKeys = catalogSkills
.filter((skill) => skill.kind === "bundled")
.map((skill) => skill.key)
.sort();
const optionalKeys = catalogSkills
.filter((skill) => skill.kind === "optional")
.map((skill) => skill.key)
.sort();
expect(bundledKeys).toEqual(EXPECTED_BUNDLED_KEYS);
expect(optionalKeys).toEqual(EXPECTED_OPTIONAL_KEYS);
});
it("keeps every shipped skill markdown-only until a script-bearing skill clears security review", () => {
const scriptBearing = catalogSkills.filter((skill) => skill.trustLevel !== "markdown_only");
expect(scriptBearing, formatViolations("script-bearing skills require security review", scriptBearing)).toEqual([]);
});
it("populates browse/search-relevant fields for every shipped skill", () => {
const issues: string[] = [];
for (const skill of catalogSkills) {
if (skill.compatibility !== "compatible") {
issues.push(`${skill.key} compatibility=${skill.compatibility}`);
}
if (!skill.description || skill.description.length < 40) {
issues.push(`${skill.key} description must be at least 40 characters for catalog browse/search`);
}
if (skill.recommendedForRoles.length === 0) {
issues.push(`${skill.key} must list recommendedForRoles`);
}
if (skill.tags.length === 0) {
issues.push(`${skill.key} must list tags`);
}
}
expect(issues).toEqual([]);
});
it("uses canonical paperclipai keys derived from kind/category/slug", () => {
const violations: string[] = [];
for (const skill of catalogSkills) {
const expectedKey = `paperclipai/${skill.kind}/${skill.category}/${skill.slug}`;
const expectedId = `paperclipai:${skill.kind}:${skill.category}:${skill.slug}`;
if (skill.key !== expectedKey) violations.push(`${skill.key} should be ${expectedKey}`);
if (skill.id !== expectedId) violations.push(`${skill.id} should be ${expectedId}`);
}
expect(violations).toEqual([]);
});
it("exposes a stable manifest header for downstream consumers", () => {
expect(catalogManifest.schemaVersion).toBe(1);
expect(catalogManifest.packageName).toBe("@paperclipai/skills-catalog");
expect(catalogSkills.length).toBe(EXPECTED_BUNDLED_KEYS.length + EXPECTED_OPTIONAL_KEYS.length);
});
it("resolves shipped skills by id, key, and unique slug", () => {
const sample = catalogSkills.find((skill) => skill.key === "paperclipai/bundled/software-development/github-pr-workflow");
expect(sample, "expected github-pr-workflow to ship in the bundled catalog").toBeDefined();
if (!sample) return;
expect(resolveCatalogSkillRef(sample.id)).toMatchObject({ key: sample.key });
expect(resolveCatalogSkillRef(sample.key)).toMatchObject({ key: sample.key });
expect(resolveCatalogSkillRef(sample.slug)).toMatchObject({ key: sample.key });
});
});
function formatViolations(label: string, skills: CatalogSkill[]) {
if (skills.length === 0) return label;
const detail = skills.map((skill) => `${skill.key} (${skill.trustLevel})`).join(", ");
return `${label}: ${detail}`;
}
+48
View File
@@ -0,0 +1,48 @@
export type CatalogSkillKind = "bundled" | "optional";
export type CatalogTrustLevel = "markdown_only" | "assets" | "scripts_executables";
export type CatalogCompatibility = "compatible" | "unknown" | "invalid";
export type CatalogSkillFileKind = "skill" | "markdown" | "reference" | "script" | "asset" | "other";
export interface CatalogSkillFile {
path: string;
kind: CatalogSkillFileKind;
sizeBytes: number;
sha256: string;
}
export interface CatalogSkill {
id: string;
key: string;
kind: CatalogSkillKind;
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: CatalogTrustLevel;
compatibility: CatalogCompatibility;
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
}
export interface CatalogManifest {
schemaVersion: 1;
packageName: "@paperclipai/skills-catalog";
packageVersion: string;
generatedAt: string;
skills: CatalogSkill[];
}
export interface CatalogValidationResult {
valid: boolean;
errors: string[];
manifest: CatalogManifest;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["generated/**/*.json", "scripts/**/*.ts", "src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+2
View File
@@ -622,6 +622,8 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/skills-catalog: {}
server:
dependencies:
'@aws-sdk/client-s3':
+101 -405
View File
@@ -14,7 +14,6 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -29,7 +28,6 @@ const accessSvc = {
const projectSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
@@ -67,26 +65,6 @@ const assetSvc = {
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record<string, unknown>),
getById: vi.fn(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
}),
resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
}),
create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
})),
getByName: vi.fn(async (_companyId: string, name: string) => null),
};
const agentInstructionsSvc = {
@@ -138,25 +116,6 @@ vi.mock("../routes/org-chart-svg.js", () => ({
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
}));
const gitSourceMock = vi.hoisted(() => ({
resolveGitRef: vi.fn(),
openRepoSnapshot: vi.fn(),
}));
// parseGitSourceUrl stays real (the shim parseGitHubSourceUrl delegates to it
// and is asserted by existing tests). Only the network-touching functions are
// overridable per-test.
vi.mock("../services/git-source.js", async () => {
const actual = await vi.importActual<typeof import("../services/git-source.js")>(
"../services/git-source.js",
);
return {
...actual,
resolveGitRef: gitSourceMock.resolveGitRef,
openRepoSnapshot: gitSourceMock.openRepoSnapshot,
};
});
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
@@ -500,6 +459,7 @@ describe("company portability", () => {
expect(extension).not.toContain("instructionsFilePath");
expect(extension).not.toContain("command:");
expect(extension).not.toContain("secretId");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).toContain("inputs:");
expect(extension).toContain("ANTHROPIC_API_KEY:");
expect(extension).toContain('requirement: "optional"');
@@ -678,6 +638,106 @@ describe("company portability", () => {
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"])).toContain("# API");
});
it("exports catalog skill provenance in portable Paperclip frontmatter", async () => {
const portability = companyPortabilityService({} as any);
const catalogKey = "paperclipai/bundled/software-development/review";
const originHash = "sha256:catalog-origin";
const catalogSkill = {
id: "skill-catalog",
companyId: "company-1",
key: catalogKey,
slug: "review",
name: "review",
description: "Catalog review skill",
markdown: "---\nname: review\ndescription: Catalog review skill\n---\n\n# Review\n",
sourceType: "catalog",
sourceLocator: "/tmp/paperclip/catalog/review",
sourceRef: originHash,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/checklist.md", kind: "reference" },
],
metadata: {
sourceKind: "catalog",
skillKey: catalogKey,
catalogId: "paperclipai:bundled:software-development:review",
catalogKey,
catalogKind: "bundled",
catalogCategory: "software-development",
catalogPath: "catalog/bundled/software-development/review",
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
originHash,
originVersion: "0.3.1",
originSnapshotLocator: "/tmp/local-only-origin",
installedHash: "sha256:installed",
userModifiedAt: "2026-05-01T00:00:00.000Z",
updateHoldReason: "local_modifications",
auditVerdict: "warning",
auditCodes: ["local_modifications"],
auditScannedAt: "2026-05-02T00:00:00.000Z",
auditScanVersion: "skills-audit-v1",
},
};
companySkillSvc.listFull.mockResolvedValue([catalogSkill]);
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => ({
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: review\ndescription: Catalog review skill\n---\n\n# Review\n"
: "# Checklist\n",
language: "markdown",
markdown: true,
editable: true,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: false,
issues: false,
skills: true,
},
expandReferencedSkills: true,
});
const skillMarkdown = asTextFile(exported.files["skills/paperclipai/bundled/software-development/review/SKILL.md"]);
expect(skillMarkdown).toContain("paperclip:");
expect(skillMarkdown).toContain("catalog:");
expect(skillMarkdown).toContain(`sourceRef: "${originHash}"`);
expect(skillMarkdown).toContain('catalogId: "paperclipai:bundled:software-development:review"');
expect(skillMarkdown).toContain(`catalogKey: "${catalogKey}"`);
expect(skillMarkdown).toContain('catalogKind: "bundled"');
expect(skillMarkdown).toContain('catalogPath: "catalog/bundled/software-development/review"');
expect(skillMarkdown).toContain('packageName: "@paperclipai/skills-catalog"');
expect(skillMarkdown).toContain('packageVersion: "0.3.1"');
expect(skillMarkdown).toContain('installedHash: "sha256:installed"');
expect(skillMarkdown).toContain('auditVerdict: "warning"');
expect(skillMarkdown).not.toContain("originSnapshotLocator");
expect(exported.manifest.skills[0]).toMatchObject({
key: catalogKey,
sourceType: "catalog",
sourceRef: originHash,
metadata: expect.objectContaining({
sourceKind: "catalog",
skillKey: catalogKey,
originHash,
catalogId: "paperclipai:bundled:software-development:review",
catalogKey,
catalogKind: "bundled",
catalogPath: "catalog/bundled/software-development/review",
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
installedHash: "sha256:installed",
auditCodes: ["local_modifications"],
}),
});
});
it("exports only selected skills when skills filter is provided", async () => {
const portability = companyPortabilityService({} as any);
@@ -1314,9 +1374,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "anthropic-api-key",
secretProvider: "local_encrypted",
type: "secret_ref",
},
{
key: "GH_TOKEN",
@@ -1327,9 +1384,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "gh-token",
secretProvider: "local_encrypted",
type: "secret_ref",
},
]);
});
@@ -1453,9 +1507,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: null,
secretProvider: null,
type: "plain",
});
});
@@ -3063,191 +3114,6 @@ describe("company portability", () => {
}));
});
describe("secret env vars", () => {
beforeEach(() => {
// Reset create/getByName to ensure clean state per test
secretSvc.create.mockReset();
secretSvc.getByName.mockReset();
secretSvc.getById.mockImplementation(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
});
secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
});
secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
}));
secretSvc.getByName.mockResolvedValue(null);
});
it("exports secret env var metadata with secretName and secretProvider", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
});
const secretInput = exported.manifest.envInputs.find(
(e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret",
);
expect(secretInput).toBeDefined();
expect(secretInput.secretName).toBe("anthropic-api-key");
expect(secretInput.secretProvider).toBe("local_encrypted");
});
it("exports secret values to manifest when includeSecrets is true", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
expect(exported.manifest.secrets).toBeDefined();
expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({
name: "anthropic-api-key",
provider: "local_encrypted",
currentValue: "sk-ant-secret-xxx",
}));
});
it("omits secrets section when includeSecrets is false", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: false,
});
expect(exported.manifest.secrets).toBeUndefined();
});
it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => {
secretSvc.resolveSecretValue.mockImplementation(async () => {
throw new Error("Decryption failed: missing master key");
});
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key");
expect(secretEntry?.currentValue).toBe("<decryption-key-missing:anthropic-api-key>");
expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export"));
});
it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const imported = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.create).toHaveBeenCalled();
expect(agentSvc.update).toHaveBeenCalledWith(
"new-agent-1",
expect.any(Object),
);
});
it("reuses existing secret on conflict during import", async () => {
secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => {
if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" };
return null;
});
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.update).toHaveBeenCalled();
});
it("exports plain env vars faithfully", async () => {
agentSvc.list.mockResolvedValue([{
id: "agent-1",
name: "TestAgent",
status: "idle",
role: "agent",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {
env: {
PLAIN_VAR: { type: "plain", value: "plain-value" },
ANOTHER_VAR: { type: "plain", value: "another-value" },
},
},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
}]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["testagent"],
});
const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain");
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "PLAIN_VAR",
defaultValue: "plain-value",
}));
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "ANOTHER_VAR",
defaultValue: "another-value",
}));
});
});
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
const portability = companyPortabilityService({} as any);
@@ -3398,173 +3264,3 @@ describe("company portability", () => {
expect(preview.plan.issuePlans).toHaveLength(0);
});
});
describe("git source orchestration via resolveSource", () => {
const minimalCompanyMarkdown = "---\ncompany:\n name: Demo\n---\n# Demo\n";
const githubUrl = "https://git.example.com/acme/co?ref=main&path=";
function makeSnapshot(overrides: {
files?: string[];
fileContents?: Record<string, string>;
binaryContents?: Record<string, Uint8Array>;
readBinaryReject?: Error;
} = {}) {
const files = overrides.files ?? ["COMPANY.md"];
const fileContents = overrides.fileContents ?? { "COMPANY.md": minimalCompanyMarkdown };
const binaryContents = overrides.binaryContents ?? {};
return {
sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
listFiles: vi.fn(async () => files),
readFile: vi.fn(async (p: string) => {
if (p in fileContents) return fileContents[p];
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
}),
readFileOptional: vi.fn(async (p: string) => fileContents[p] ?? null),
readBinary: vi.fn(async (p: string) => {
if (overrides.readBinaryReject) throw overrides.readBinaryReject;
if (p in binaryContents) return binaryContents[p]!;
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
}),
};
}
function setupResolveStub() {
gitSourceMock.resolveGitRef.mockResolvedValue({
pinnedSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
trackingRef: "main",
});
}
beforeEach(() => {
gitSourceMock.resolveGitRef.mockReset();
gitSourceMock.openRepoSnapshot.mockReset();
companySvc.getById.mockResolvedValue(null);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
issueSvc.listComments.mockResolvedValue([]);
companySkillSvc.list.mockResolvedValue([]);
});
it("opens a snapshot and walks the tree for a github source", async () => {
setupResolveStub();
const snapshot = makeSnapshot({
files: ["COMPANY.md", "README.md", "skills/x/SKILL.md"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
"README.md": "# readme",
"skills/x/SKILL.md": "---\nname: x\n---\n",
},
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(gitSourceMock.resolveGitRef).toHaveBeenCalledTimes(1);
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(1);
expect(snapshot.listFiles).toHaveBeenCalled();
expect(snapshot.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
expect(snapshot.readFile).toHaveBeenCalledWith("README.md");
expect(snapshot.readFile).toHaveBeenCalledWith("skills/x/SKILL.md");
expect(preview.errors).toEqual([]);
});
it("falls back from main to master when the main ref does not exist", async () => {
setupResolveStub();
const masterSnap = makeSnapshot();
// First call (ref=main) rejects; second (ref=master) succeeds.
gitSourceMock.openRepoSnapshot
.mockRejectedValueOnce(new Error("ref not found"))
.mockResolvedValueOnce(masterSnap);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(2);
expect(masterSnap.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
expect(preview.warnings).toContain("Git ref main not found; falling back to master.");
});
it("throws when COMPANY.md is missing on both main and master", async () => {
setupResolveStub();
const emptySnap = makeSnapshot({ fileContents: {} });
gitSourceMock.openRepoSnapshot.mockResolvedValue(emptySnap);
const portability = companyPortabilityService({} as any);
await expect(
portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
}),
).rejects.toThrow(/missing COMPANY.md/i);
});
it("fetches a referenced company logo as binary", async () => {
setupResolveStub();
// logoPath lives in .paperclip.yaml (paperclip extension), not COMPANY.md.
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
const logoBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const snapshot = makeSnapshot({
files: ["COMPANY.md", ".paperclip.yaml", "images/logo.png"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
".paperclip.yaml": paperclipYaml,
},
binaryContents: { "images/logo.png": logoBytes },
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(snapshot.readBinary).toHaveBeenCalledWith("images/logo.png");
});
it("warns instead of throwing when the logo blob can't be read", async () => {
setupResolveStub();
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
const snapshot = makeSnapshot({
files: ["COMPANY.md", ".paperclip.yaml"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
".paperclip.yaml": paperclipYaml,
},
readBinaryReject: new Error("blob missing"),
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(snapshot.readBinary).toHaveBeenCalled();
expect(preview.warnings.some((w: string) => /Failed to fetch company logo/i.test(w))).toBe(true);
});
});
@@ -216,7 +216,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
return {
companyId,
agentId,
environment: {
id: environmentId,
companyId,
@@ -1395,298 +1394,4 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(sshRelease).not.toHaveBeenCalled();
expect(acquired.lease.metadata?.driver).toBe("local");
});
// -------------------------------------------------------------------------
// agentId is threaded through plugin RPC params (see protocol.ts —
// PluginEnvironmentAcquireLeaseParams.agentId and
// PluginEnvironmentResumeLeaseParams.agentId). Plugin-backed sandbox
// providers can use this to scope lease state (subdirs, PVCs, etc.) per
// agent without callbacks or DB lookups. The runtime must forward it when
// present and omit it when null/undefined so older plugin SDKs that don't
// declare the field aren't surprised.
// -------------------------------------------------------------------------
it("plugin-driver acquireLease: forwards agentId in the RPC payload when present", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "plugin-lease-agent", metadata: { remoteCwd: "/workspace" } };
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin agentId fwd",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: { template: "base" },
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"environmentAcquireLease",
expect.objectContaining({ agentId }),
);
});
it("plugin-driver acquireLease: omits agentId from RPC payload when null", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "plugin-lease-no-agent", metadata: { remoteCwd: "/workspace" } };
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin agentId null",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: { template: "base" },
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
const payload = (workerManager.call as ReturnType<typeof vi.fn>).mock.calls.find(
([, method]) => method === "environmentAcquireLease",
)?.[2] as Record<string, unknown>;
expect(payload).toBeDefined();
expect(payload.agentId).toBeUndefined();
expect("agentId" in payload).toBe(false);
});
it("sandbox-provider acquireLease: forwards agentId when present", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "sandbox-agent-1", metadata: { reuseLease: false } };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Sandbox agentId fwd",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 30_000,
reuseLease: false,
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.sandbox",
packageName: "@acme/paperclip-sandbox",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.sandbox",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Sandbox",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake",
configSchema: { type: "object" },
}],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"environmentAcquireLease",
expect.objectContaining({ agentId }),
expect.any(Number),
);
});
it("sandbox-provider resumeLease: forwards agentId when present", async () => {
const pluginId = randomUUID();
const calls: { method: string; params: Record<string, unknown> }[] = [];
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, params: Record<string, unknown>) => {
calls.push({ method, params });
if (method === "environmentAcquireLease") {
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
}
if (method === "environmentResumeLease") {
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Sandbox agentId resume",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 30_000,
reuseLease: true,
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.sandbox",
packageName: "@acme/paperclip-sandbox",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.sandbox",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Sandbox",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake",
configSchema: { type: "object" },
}],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
// First acquire seeds a reusable lease row in DB
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
// Second acquire on the same environment + reuseLease=true exercises the
// resume path (host's matcher finds the reusable lease, plugin's
// resumeLease is invoked).
const newRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: newRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: newRunId,
persistedExecutionWorkspace: null,
});
const resumeCall = calls.find((c) => c.method === "environmentResumeLease");
expect(resumeCall).toBeDefined();
expect(resumeCall?.params.agentId).toBe(agentId);
});
});
-410
View File
@@ -1,410 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const listServerRefs = vi.fn();
const cloneFn = vi.fn();
const walkFn = vi.fn();
const readBlobFn = vi.fn();
const resolveRefFn = vi.fn();
const treeFn = vi.fn((args: unknown) => ({ __tree: args }));
vi.mock("isomorphic-git", () => ({
default: {
listServerRefs: (...args: unknown[]) => listServerRefs(...args),
clone: (...args: unknown[]) => cloneFn(...args),
walk: (...args: unknown[]) => walkFn(...args),
readBlob: (...args: unknown[]) => readBlobFn(...args),
resolveRef: (...args: unknown[]) => resolveRefFn(...args),
TREE: (...args: unknown[]) => treeFn(...args),
},
}));
vi.mock("isomorphic-git/http/node", () => ({
default: { request: vi.fn() },
}));
const { parseGitSourceUrl, resolveGitRef, openRepoSnapshot, buildCloneUrl } =
await import("../services/git-source.js");
beforeEach(() => {
listServerRefs.mockReset();
cloneFn.mockReset();
walkFn.mockReset();
readBlobFn.mockReset();
resolveRefFn.mockReset();
treeFn.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("parseGitSourceUrl", () => {
it("parses a bare github repo URL", () => {
expect(parseGitSourceUrl("https://github.com/anthropics/claude-code")).toMatchObject({
cloneUrl: "https://github.com/anthropics/claude-code.git",
hostname: "github.com",
owner: "anthropics",
repo: "claude-code",
ref: null,
basePath: "",
filePath: null,
explicitRef: false,
});
});
it("strips trailing .git from the repo segment", () => {
expect(parseGitSourceUrl("https://example.com/o/r.git")).toMatchObject({
cloneUrl: "https://example.com/o/r.git",
repo: "r",
});
});
it("parses a github tree URL with subpath", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/tree/develop/sub/dir"),
).toMatchObject({
ref: "develop",
basePath: "sub/dir",
filePath: null,
explicitRef: true,
});
});
it("parses a github blob URL as a file path", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/blob/main/path/to/file.md"),
).toMatchObject({
ref: "main",
basePath: "path/to",
filePath: "path/to/file.md",
explicitRef: true,
});
});
it("parses a gitea src/branch URL with subpath", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/branch/main/skills"),
).toMatchObject({
cloneUrl: "https://git.example.com/o/r.git",
ref: "main",
basePath: "skills",
filePath: null,
explicitRef: true,
});
});
it("parses a gitea src/tag URL", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.2.3"),
).toMatchObject({
ref: "v1.2.3",
basePath: "",
explicitRef: true,
});
});
it("parses a gitea src/commit URL with file", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/commit/abc123/dir/SKILL.md"),
).toMatchObject({
ref: "abc123",
basePath: "dir",
filePath: "dir/SKILL.md",
});
});
it("parses a gitlab tree URL", () => {
expect(
parseGitSourceUrl("https://gitlab.com/group/proj/-/tree/main/sub"),
).toMatchObject({
cloneUrl: "https://gitlab.com/group/proj.git",
ref: "main",
basePath: "sub",
explicitRef: true,
});
});
it("parses a gitlab blob URL", () => {
expect(
parseGitSourceUrl("https://gitlab.com/group/proj/-/blob/main/sub/file.md"),
).toMatchObject({
ref: "main",
filePath: "sub/file.md",
basePath: "sub",
});
});
it("rejects non-https URLs", () => {
expect(() => parseGitSourceUrl("http://github.com/o/r")).toThrow(/HTTPS/);
});
it("rejects URLs without owner/repo", () => {
expect(() => parseGitSourceUrl("https://github.com/o")).toThrow();
});
it("rejects malformed URLs", () => {
expect(() => parseGitSourceUrl("not a url")).toThrow();
});
it("parses a query-string URL with ?ref= and ?path=", () => {
expect(
parseGitSourceUrl("https://github.com/o/r?ref=feature%2Fdemo&path=subdir"),
).toMatchObject({
cloneUrl: "https://github.com/o/r.git",
ref: "feature/demo",
basePath: "subdir",
filePath: null,
explicitRef: true,
});
});
it("parses a query-string URL with only ?ref=", () => {
expect(parseGitSourceUrl("https://github.com/o/r?ref=develop")).toMatchObject({
ref: "develop",
basePath: "",
explicitRef: true,
});
});
it("parses a query-string URL with only ?path=", () => {
expect(parseGitSourceUrl("https://github.com/o/r?path=sub")).toMatchObject({
ref: null,
basePath: "sub",
explicitRef: false,
});
});
it("query-string parsing takes precedence over path-style segments", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/tree/main/old?ref=newref&path=newpath"),
).toMatchObject({
ref: "newref",
basePath: "newpath",
});
});
});
describe("buildCloneUrl", () => {
it("produces a .git suffix URL on the given host", () => {
expect(buildCloneUrl("git.example.com", "o", "r")).toBe(
"https://git.example.com/o/r.git",
);
});
});
describe("resolveGitRef", () => {
it("passes through a 40-hex SHA without hitting the network", async () => {
const parsed = parseGitSourceUrl(
"https://github.com/o/r/tree/0123456789abcdef0123456789abcdef01234567",
);
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "0123456789abcdef0123456789abcdef01234567",
trackingRef: "0123456789abcdef0123456789abcdef01234567",
});
expect(listServerRefs).not.toHaveBeenCalled();
});
it("returns default branch via HEAD symref when ref is absent", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
{ ref: "refs/heads/chore", oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
trackingRef: "main",
});
expect(listServerRefs).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://git.example.com/o/r.git",
symrefs: true,
protocolVersion: 2,
}),
);
});
it("resolves a named branch to its SHA", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
{ ref: "refs/heads/develop", oid: "2222222222222222222222222222222222222222" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/develop");
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "2222222222222222222222222222222222222222",
trackingRef: "develop",
});
});
it("prefers a peeled annotated tag over the tag object", async () => {
listServerRefs.mockResolvedValue([
{ ref: "refs/tags/v1.0", oid: "tttttttttttttttttttttttttttttttttttttttt" },
{ ref: "refs/tags/v1.0^{}", oid: "cccccccccccccccccccccccccccccccccccccccc" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.0");
const result = await resolveGitRef(parsed);
expect(result.pinnedSha).toBe("cccccccccccccccccccccccccccccccccccccccc");
expect(result.trackingRef).toBe("v1.0");
});
it("resolves a lightweight tag when no peeled entry exists", async () => {
listServerRefs.mockResolvedValue([
{ ref: "refs/tags/v2.0", oid: "dddddddddddddddddddddddddddddddddddddddd" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v2.0");
const result = await resolveGitRef(parsed);
expect(result.pinnedSha).toBe("dddddddddddddddddddddddddddddddddddddddd");
});
it("throws when an explicit ref does not exist", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "9999999999999999999999999999999999999999", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "9999999999999999999999999999999999999999" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/missing");
await expect(resolveGitRef(parsed)).rejects.toThrow(/Ref 'missing' not found/);
});
it("translates network errors into a user-facing message", async () => {
listServerRefs.mockRejectedValue(new Error("ENOTFOUND git.invalid"));
const parsed = parseGitSourceUrl("https://git.invalid/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/could not connect/i);
});
it("translates 401 errors into an auth message", async () => {
listServerRefs.mockRejectedValue(new Error("HTTP Error: 401 Unauthorized"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/authentication/i);
});
it("translates 404 errors into a repo-not-found message", async () => {
listServerRefs.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/repository not found/i);
});
it("sends an onAuth callback when a token is supplied", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await resolveGitRef(parsed, "tok_abc");
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth: () => unknown };
expect(typeof callArgs.onAuth).toBe("function");
expect(callArgs.onAuth()).toEqual({ username: "tok_abc", password: "x-oauth-basic" });
});
it("omits onAuth when no token is supplied", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await resolveGitRef(parsed);
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth?: unknown };
expect(callArgs.onAuth).toBeUndefined();
});
});
describe("openRepoSnapshot", () => {
it("clones at the tracking ref and walks the tree at the resolved SHA", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async ({ map }: { map: (filepath: string, entries: Array<{ type: () => Promise<string> }>) => Promise<void> }) => {
await map(".", [{ type: () => Promise.resolve("tree") }]);
await map("README.md", [{ type: () => Promise.resolve("blob") }]);
await map("skills/x/SKILL.md", [{ type: () => Promise.resolve("blob") }]);
await map("skills/x", [{ type: () => Promise.resolve("tree") }]);
});
readBlobFn.mockResolvedValue({ blob: new TextEncoder().encode("hello") });
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff", "tok");
expect(cloneFn).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://git.example.com/o/r.git",
ref: "main",
singleBranch: true,
depth: 1,
noCheckout: true,
}),
);
expect(snap.sha).toBe("ffffffffffffffffffffffffffffffffffffffff");
const files = await snap.listFiles();
expect(files).toEqual(["README.md", "skills/x/SKILL.md"]);
const content = await snap.readFile("README.md");
expect(content).toBe("hello");
expect(readBlobFn).toHaveBeenCalledWith(
expect.objectContaining({
oid: "ffffffffffffffffffffffffffffffffffffffff",
filepath: "README.md",
}),
);
});
it("falls back to the expected SHA as ref when no tracking ref is known", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("abc1234567890abc1234567890abc1234567890a");
walkFn.mockImplementation(async () => {});
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await openRepoSnapshot(parsed, null, "abc1234567890abc1234567890abc1234567890a");
expect(cloneFn).toHaveBeenCalledWith(
expect.objectContaining({ ref: "abc1234567890abc1234567890abc1234567890a" }),
);
});
it("surfaces a 404 from clone as repository-not-found", async () => {
cloneFn.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(
openRepoSnapshot(parsed, "main", "1111111111111111111111111111111111111111"),
).rejects.toThrow(/repository not found/i);
});
it("readBinary returns the raw blob bytes", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
readBlobFn.mockResolvedValue({ blob: bytes });
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
const result = await snap.readBinary("logo.png");
expect(result).toBe(bytes);
});
it("readFileOptional returns null on NotFoundError", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
const err = Object.assign(new Error("missing"), { code: "NotFoundError" });
readBlobFn.mockRejectedValue(err);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
const result = await snap.readFileOptional("missing.md");
expect(result).toBeNull();
});
it("readFileOptional rethrows non-NotFound errors", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
readBlobFn.mockRejectedValue(new Error("disk explosion"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
await expect(snap.readFileOptional("any.md")).rejects.toThrow(/disk explosion/);
});
});
+6 -1
View File
@@ -1218,9 +1218,13 @@ export function agentRoutes(
companyId: string,
adapterType: string,
config: Record<string, unknown>,
options: {
materializeMissing?: boolean;
} = {},
) {
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
materializeMissing: options.materializeMissing
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
});
return {
...config,
@@ -1487,6 +1491,7 @@ export function agentRoutes(
agent.companyId,
agent.adapterType,
runtimeConfig,
{ materializeMissing: false },
);
const snapshot = await adapter.listSkills({
agentId: agent.id,
+166 -39
View File
@@ -1,17 +1,21 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
catalogSkillListQuerySchema,
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillInstallCatalogSchema,
companySkillInstallUpdateSchema,
companySkillProjectScanRequestSchema,
companySkillResetSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
import { getCatalogSkillOrThrow, listCatalogSkills, readCatalogSkillFile } from "../services/skills-catalog.js";
import { forbidden } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
import { getTelemetryClient } from "../telemetry.js";
type SkillTelemetryInput = {
@@ -33,6 +37,12 @@ export function companySkillRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
if (skill.sourceType === "skills_sh") {
return skill.key;
@@ -40,9 +50,19 @@ export function companySkillRoutes(db: Db) {
if (skill.sourceType !== "github") {
return null;
}
const hostname = asString(skill.metadata?.hostname);
if (hostname !== "github.com") {
return null;
}
return skill.key;
}
function firstQueryString(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
return undefined;
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -72,6 +92,29 @@ export function companySkillRoutes(db: Db) {
throw forbidden("Missing permission: can create agents");
}
router.get("/skills/catalog", async (req, res) => {
assertAuthenticated(req);
const query = catalogSkillListQuerySchema.parse({
kind: firstQueryString(req.query.kind),
category: firstQueryString(req.query.category),
q: firstQueryString(req.query.q),
});
res.json(listCatalogSkills(query));
});
router.get("/skills/catalog/:catalogId/files", async (req, res) => {
assertAuthenticated(req);
const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string);
const relativePath = firstQueryString(req.query.path) ?? "SKILL.md";
res.json(await readCatalogSkillFile(catalogRef, relativePath));
});
router.get("/skills/catalog/:catalogId", async (req, res) => {
assertAuthenticated(req);
const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string);
res.json(getCatalogSkillOrThrow(catalogRef));
});
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -185,8 +228,7 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
const result = await svc.importFromSource(companyId, source, authToken || undefined);
const result = await svc.importFromSource(companyId, source);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -219,6 +261,38 @@ export function companySkillRoutes(db: Db) {
},
);
router.post(
"/companies/:companyId/skills/install-catalog",
validate(companySkillInstallCatalogSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installFromCatalog(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: result.action === "created" ? "company.skill_catalog_installed" : "company.skill_catalog_updated",
entityType: "company_skill",
entityId: result.skill.id,
details: {
action: result.action,
catalogId: result.catalogSkill.id,
catalogKey: result.catalogSkill.key,
slug: result.skill.slug,
originHash: result.catalogSkill.contentHash,
warningCount: result.warnings.length,
},
});
res.status(result.action === "created" ? 201 : 200).json(result);
},
);
router.post(
"/companies/:companyId/skills/scan-projects",
validate(companySkillProjectScanRequestSchema),
@@ -281,44 +355,13 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installUpdate(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_update_installed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
sourceRef: result.sourceRef,
},
});
res.json(result);
});
router.patch(
"/companies/:companyId/skills/:skillId/auth",
validate(companySkillUpdateAuthSchema),
router.post(
"/companies/:companyId/skills/:skillId/audit",
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const authToken = req.body.authToken as string | null;
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
const result = await svc.auditSkill(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
@@ -331,11 +374,95 @@ export function companySkillRoutes(db: Db) {
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
action: "company.skill_audited",
entityType: "company_skill",
entityId: skillId,
details: {
verdict: result.verdict,
codes: result.codes,
installedHash: result.installedHash,
originHash: result.originHash,
scanVersion: result.scanVersion,
},
});
res.json(result);
},
);
router.post(
"/companies/:companyId/skills/:skillId/install-update",
validate(companySkillInstallUpdateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const before = await svc.getById(companyId, skillId);
const result = await svc.installUpdate(companyId, skillId, req.body);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_update_installed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null,
previousOriginVersion: before?.metadata?.originVersion ?? null,
newOriginHash: result.metadata?.originHash ?? result.sourceRef,
newOriginVersion: result.metadata?.originVersion ?? null,
driftDetected: Boolean(before?.metadata?.userModifiedAt),
force: Boolean(req.body.force),
auditVerdict: result.metadata?.auditVerdict ?? null,
},
});
res.json(result);
},
);
router.post(
"/companies/:companyId/skills/:skillId/reset",
validate(companySkillResetSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const before = await svc.getById(companyId, skillId);
const result = await svc.resetSkill(companyId, skillId, req.body);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_reset",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null,
previousOriginVersion: before?.metadata?.originVersion ?? null,
newOriginHash: result.metadata?.originHash ?? result.sourceRef,
newOriginVersion: result.metadata?.originVersion ?? null,
driftDetected: Boolean(before?.metadata?.userModifiedAt),
force: Boolean(req.body.force),
auditVerdict: result.metadata?.auditVerdict ?? null,
},
});
+65
View File
@@ -0,0 +1,65 @@
export const PORTABLE_CATALOG_PROVENANCE_STRING_KEYS = [
"sourceRef",
"originHash",
"catalogId",
"catalogKey",
"catalogKind",
"catalogCategory",
"catalogPath",
"packageName",
"packageVersion",
"originVersion",
"installedHash",
"userModifiedAt",
"updateHoldReason",
"auditVerdict",
"auditScannedAt",
"auditScanVersion",
] as const;
function asCatalogString(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function readCatalogStringList(value: unknown) {
if (!Array.isArray(value)) return null;
const entries = value.map((entry) => asCatalogString(entry)).filter((entry): entry is string => Boolean(entry));
return entries.length === value.length ? entries : null;
}
function isCatalogRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readPortableCatalogProvenance(
metadata: Record<string, unknown> | null,
canonicalKey: string | null = null,
) {
const paperclip = isCatalogRecord(metadata?.paperclip) ? metadata.paperclip : null;
const catalog = isCatalogRecord(paperclip?.catalog) ? paperclip.catalog : null;
if (!catalog) return null;
const sourceRef = asCatalogString(catalog.sourceRef) ?? asCatalogString(catalog.originHash);
const normalized: Record<string, unknown> = {
...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "catalog",
};
const catalogSkillKey = asCatalogString(catalog.skillKey);
if (!canonicalKey && catalogSkillKey) normalized.skillKey = catalogSkillKey;
for (const key of PORTABLE_CATALOG_PROVENANCE_STRING_KEYS) {
if (key === "sourceRef") continue;
const value = asCatalogString(catalog[key]);
if (value) normalized[key] = value;
}
if (sourceRef && !normalized.originHash) normalized.originHash = sourceRef;
const auditCodes = readCatalogStringList(catalog.auditCodes);
if (auditCodes) normalized.auditCodes = auditCodes;
return {
sourceRef,
metadata: normalized,
};
}
+180 -341
View File
@@ -27,11 +27,9 @@ import type {
CompanyPortabilityIssueManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilitySecretEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
SecretProvider,
} from "@paperclipai/shared";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
@@ -56,8 +54,8 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { openRepoSnapshot, parseGitSourceUrl, resolveGitRef, type RepoSnapshot } from "./git-source.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
import { agentService } from "./agents.js";
@@ -72,6 +70,12 @@ import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
import {
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
readCatalogStringList,
readPortableCatalogProvenance,
} from "./catalog-provenance.js";
import { normalizePortablePath } from "./portable-path.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
@@ -230,6 +234,28 @@ function readSkillSourceKind(skill: CompanySkill) {
return asString(metadata?.sourceKind);
}
function buildPortableCatalogProvenance(skill: CompanySkill) {
if (skill.sourceType !== "catalog") return null;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
const provenance: Record<string, unknown> = {
skillKey: skill.key,
};
const sourceRef = asString(skill.sourceRef) ?? asString(metadata?.originHash);
if (sourceRef) provenance.sourceRef = sourceRef;
for (const key of PORTABLE_CATALOG_PROVENANCE_STRING_KEYS) {
if (key === "sourceRef") continue;
const value = asString(metadata?.[key]);
if (value) provenance[key] = value;
}
const auditCodes = readCatalogStringList(metadata?.auditCodes);
if (auditCodes) provenance.auditCodes = auditCodes;
return Object.keys(provenance).length > 1 ? provenance : null;
}
function deriveLocalExportNamespace(skill: CompanySkill, slug: string) {
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
const candidates = [
@@ -405,7 +431,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
return parsed.success ? parsed.data : null;
}
async function extractPortableScopedEnvInputs(
function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
@@ -414,11 +440,7 @@ async function extractPortableScopedEnvInputs(
},
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
@@ -430,7 +452,6 @@ async function extractPortableScopedEnvInputs(
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
const secret = await secrets.getById(String(binding.secretId));
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
@@ -440,33 +461,7 @@ async function extractPortableScopedEnvInputs(
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: secret?.name ?? null,
secretProvider: secret?.provider ?? null,
});
if (includeSecrets && secret && binding.secretId) {
const alreadyExported = secretEntries.some((e) => e.name === secret.name);
if (!alreadyExported) {
try {
const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest");
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: resolvedValue,
});
} catch {
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: `<decryption-key-missing:${secret.name}>`,
});
warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`);
}
}
}
continue;
}
@@ -476,6 +471,9 @@ async function extractPortableScopedEnvInputs(
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -491,6 +489,9 @@ async function extractPortableScopedEnvInputs(
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -598,14 +599,11 @@ type AgentLike = {
};
type EnvInputRecord = {
type?: "secret_ref" | "plain";
kind: "secret" | "plain";
requirement: "required" | "optional";
default?: string | null;
description?: string | null;
portability?: "portable" | "system_dependent";
secretName?: string | null;
secretProvider?: string | null;
};
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
@@ -1445,20 +1443,6 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
};
}
function normalizePortablePath(input: string) {
const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "");
const parts: string[] = [];
for (const segment of normalized.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(segment);
}
return parts.join("/");
}
function resolvePortablePath(fromPath: string, targetPath: string) {
const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/"));
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
@@ -1747,15 +1731,11 @@ function isAbsoluteCommand(value: string) {
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
}
async function extractPortableEnvInputs(
function extractPortableEnvInputs(
agentSlug: string,
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
@@ -1765,22 +1745,14 @@ async function extractPortableEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
async function extractPortableProjectEnvInputs(
function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
@@ -1790,10 +1762,6 @@ async function extractPortableProjectEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
@@ -2172,12 +2140,14 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
if (sourceEntry) {
metadata.sources = [...existingSources, sourceEntry];
}
const catalogProvenance = buildPortableCatalogProvenance(skill);
metadata.skillKey = skill.key;
metadata.paperclipSkillKey = skill.key;
metadata.paperclip = {
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
skillKey: skill.key,
slug: skill.slug,
...(catalogProvenance ? { catalog: catalogProvenance } : {}),
};
const frontmatter = {
...parsed.frontmatter,
@@ -2339,6 +2309,42 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
};
}
async function fetchText(url: string) {
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchOptionalText(url: string) {
const response = await ghFetch(url);
if (response.status === 404) return null;
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchBinary(url: string) {
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await ghFetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.json() as Promise<T>;
}
function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
const seen = new Set<string>();
@@ -2362,13 +2368,6 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
if (input.defaultValue !== null) entry.default = input.defaultValue;
if (input.description) entry.description = input.description;
if (input.portability === "system_dependent") entry.portability = "system_dependent";
if (input.secretName) {
entry.secretName = input.secretName;
entry.type = "secret_ref";
} else {
entry.type = "plain";
}
if (input.secretProvider) entry.secretProvider = input.secretProvider;
env[input.key] = entry;
}
return env;
@@ -2413,9 +2412,6 @@ function readAgentEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2440,9 +2436,6 @@ function readProjectEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2489,7 +2482,6 @@ function buildManifestFromPackageFiles(
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : [];
const companyName =
asString(companyFrontmatter.name)
?? opts?.sourceLabel?.companyName
@@ -2573,7 +2565,6 @@ function buildManifestFromPackageFiles(
projects: [],
issues: [],
envInputs: [],
secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined,
};
const warnings: string[] = [];
@@ -2693,10 +2684,17 @@ function buildManifestFromPackageFiles(
normalizedMetadata = {
sourceKind: "url",
};
} else if (metadata) {
normalizedMetadata = {
sourceKind: "catalog",
};
} else {
const catalogProvenance = readPortableCatalogProvenance(metadata);
if (catalogProvenance) {
sourceType = "catalog";
sourceRef = catalogProvenance.sourceRef;
normalizedMetadata = catalogProvenance.metadata;
} else if (metadata) {
normalizedMetadata = {
sourceKind: "catalog",
};
}
}
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
@@ -2828,37 +2826,52 @@ function normalizeGitHubSourcePath(value: string | null | undefined) {
export function parseGitHubSourceUrl(rawUrl: string) {
const url = new URL(rawUrl);
// Handle the portability-specific companyPath query param before delegating,
// since git-source has no notion of it.
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
const parsed = parseGitSourceUrl(rawUrl);
let companyPath: string;
let basePath = parsed.basePath;
if (queryCompanyPath) {
companyPath = queryCompanyPath;
if (!basePath) {
const derived = path.posix.dirname(companyPath);
basePath = derived === "." ? "" : derived;
}
} else if (parsed.filePath) {
// blob-style URL pointed directly at a file
companyPath = parsed.filePath;
} else if (basePath) {
companyPath = `${basePath}/COMPANY.md`;
} else {
companyPath = "COMPANY.md";
if (url.protocol !== "https:") {
throw unprocessable("GitHub source URL must use HTTPS");
}
return {
hostname: parsed.hostname,
owner: parsed.owner,
repo: parsed.repo,
ref: parsed.ref ?? "main",
basePath,
companyPath,
};
const hostname = url.hostname;
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) {
throw unprocessable("Invalid GitHub URL");
}
const owner = parts[0]!;
const repo = parts[1]!.replace(/\.git$/i, "");
const queryRef = url.searchParams.get("ref")?.trim();
const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path"));
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
if (queryRef || queryPath || queryCompanyPath) {
const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md";
let basePath = queryPath;
if (!basePath && companyPath !== "COMPANY.md") {
basePath = path.posix.dirname(companyPath);
if (basePath === ".") basePath = "";
}
return {
hostname,
owner,
repo,
ref: queryRef || "main",
basePath,
companyPath,
};
}
let ref = "main";
let basePath = "";
let companyPath = "COMPANY.md";
if (parts[2] === "tree") {
ref = parts[3] ?? "main";
basePath = parts.slice(4).join("/");
} else if (parts[2] === "blob") {
ref = parts[3] ?? "main";
const blobPath = parts.slice(4).join("/");
if (!blobPath) {
throw unprocessable("Invalid GitHub blob URL");
}
companyPath = blobPath;
basePath = path.posix.dirname(blobPath);
if (basePath === ".") basePath = "";
}
return { hostname, owner, repo, ref, basePath, companyPath };
}
@@ -2962,38 +2975,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
);
}
const sourceUrl = source.url;
const parsed = parseGitHubSourceUrl(sourceUrl);
const parsed = parseGitHubSourceUrl(source.url);
let ref = parsed.ref;
const warnings: string[] = [];
const companyRelativePath = parsed.companyPath === "COMPANY.md"
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
: parsed.companyPath;
async function openSnapshot(refName: string): Promise<RepoSnapshot> {
const ps = parseGitSourceUrl(sourceUrl);
const wanted = { ...ps, ref: refName, explicitRef: true };
const resolved = await resolveGitRef(wanted);
return openRepoSnapshot(wanted, resolved.trackingRef, resolved.pinnedSha);
}
let ref = parsed.ref;
let snapshot: RepoSnapshot;
let companyMarkdown: string | null = null;
try {
snapshot = await openSnapshot(ref);
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
);
} catch (err) {
if (ref === "main") {
ref = "master";
warnings.push("Git ref main not found; falling back to master.");
snapshot = await openSnapshot(ref);
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
warnings.push("GitHub ref main not found; falling back to master.");
companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
);
} else {
throw err;
}
}
if (!companyMarkdown) {
throw unprocessable("Git company package is missing COMPANY.md");
throw unprocessable("GitHub company package is missing COMPANY.md");
}
const companyPath = parsed.companyPath === "COMPANY.md"
@@ -3002,22 +3007,31 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = {
[companyPath]: companyMarkdown,
};
const apiBase = gitHubApiBase(parsed.hostname);
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
).catch(() => ({ tree: [] }));
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
const allPaths = await snapshot.listFiles();
const candidatePaths = allPaths.filter((entry) => {
if (basePrefix && !entry.startsWith(basePrefix)) return false;
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
return (
relative.endsWith(".md") ||
relative.startsWith("skills/") ||
relative === ".paperclip.yaml" ||
relative === ".paperclip.yml"
);
});
const candidatePaths = (tree.tree ?? [])
.filter((entry) => entry.type === "blob")
.map((entry) => entry.path)
.filter((entry): entry is string => typeof entry === "string")
.filter((entry) => {
if (basePrefix && !entry.startsWith(basePrefix)) return false;
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
return (
relative.endsWith(".md") ||
relative.startsWith("skills/") ||
relative === ".paperclip.yaml" ||
relative === ".paperclip.yml"
);
});
for (const repoPath of candidatePaths) {
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
if (files[relativePath] !== undefined) continue;
files[normalizePortablePath(relativePath)] = await snapshot.readFile(repoPath);
files[normalizePortablePath(relativePath)] = await fetchText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
}
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
@@ -3026,7 +3040,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const relativePath = normalizePortablePath(includeEntry.path);
if (files[relativePath] !== undefined) continue;
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
files[relativePath] = await snapshot.readFile(repoPath);
files[relativePath] = await fetchText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
}
const resolved = buildManifestFromPackageFiles(files);
@@ -3034,13 +3050,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (companyLogoPath && !resolved.files[companyLogoPath]) {
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
try {
const binary = await snapshot.readBinary(repoPath);
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(
Buffer.from(binary),
inferContentTypeFromPath(companyLogoPath),
const binary = await fetchBinary(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
} catch (err) {
warnings.push(`Failed to fetch company logo ${companyLogoPath} from git: ${err instanceof Error ? err.message : String(err)}`);
warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`);
}
}
resolved.warnings.unshift(...warnings);
@@ -3067,9 +3082,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = {};
const warnings: string[] = [];
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
const secretEntries: CompanyPortabilitySecretEntry[] = [];
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
const includeSecrets = input.includeSecrets === true;
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
let companyLogoPath: string | null = null;
@@ -3349,14 +3362,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(...exportedInstructions.warnings);
const envInputsStart = envInputs.length;
const exportedEnvInputs = await extractPortableEnvInputs(
const exportedEnvInputs = extractPortableEnvInputs(
slug,
(agent.adapterConfig as Record<string, unknown>).env,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
envInputs.push(...exportedEnvInputs);
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
@@ -3433,7 +3442,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId);
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
@@ -3653,20 +3662,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
if (includeSecrets) {
resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined;
}
resolved.warnings.unshift(...warnings);
// Rebuild the YAML file to include secrets so files stay in sync with manifest
// Only include secrets - other fields should come from the original YAML structure
if (includeSecrets && resolved.manifest.secrets) {
// Parse existing YAML and add secrets to it
const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {};
existingYaml.secrets = resolved.manifest.secrets;
finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true });
}
return {
rootPath,
manifest: resolved.manifest,
@@ -4231,7 +4228,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const secretNameToId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
@@ -4263,35 +4259,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Create secrets in target company and build name->id map
for (const secretEntry of sourceManifest.secrets ?? []) {
if (secretEntry.currentValue.startsWith("<decryption-key-missing:")) {
warnings.push(`Secret "${secretEntry.name}" could not be decrypted in source instance. ` +
`Placeholder written for key. Create a secret with this name and update manually.`);
continue;
}
try {
const created = await secrets.create(targetCompany.id, {
name: secretEntry.name,
provider: secretEntry.provider,
value: secretEntry.currentValue,
description: secretEntry.description,
});
secretNameToId.set(secretEntry.name, created.id);
} catch (err) {
if (err instanceof HttpError && err.status === 409) {
const existing = await secrets.getByName(targetCompany.id, secretEntry.name);
if (existing) {
secretNameToId.set(secretEntry.name, existing.id);
} else {
warnings.push(`Secret "${secretEntry.name}" already exists but could not be resolved by name. Re-add env bindings for this secret manually.`);
}
} else {
warnings.push(`Failed to create secret "${secretEntry.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
if (include.agents) {
for (const planAgent of plan.preview.plan.agentPlans) {
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
@@ -4348,30 +4315,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
desiredSkills,
mode,
);
// Reconstruct adapterConfig.env from manifest.envInputs for this agent
const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug);
if (agentEnvInputs.length > 0) {
const env: Record<string, unknown> = {};
for (const ei of agentEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
env[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
env[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
if (Object.keys(env).length > 0) {
normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode });
}
}
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4422,9 +4365,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
const createdStatus = "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: "idle",
status: createdStatus,
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
@@ -4444,7 +4388,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? "idle");
agentStatusById.set(created.id, created.status ?? createdStatus);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4493,26 +4437,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
?? null
: null;
const projectWorkspaceIdByKey = new Map<string, string>();
// Build project env from manifest.envInputs filtered by this project
const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug);
const reconstructedProjectEnv: Record<string, unknown> = {};
for (const ei of projectEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0
? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode })
: null;
const projectPatch = {
name: planProject.plannedName,
description: manifestProject.description,
@@ -4522,7 +4446,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
env: projectEnvConfig ?? undefined,
env: manifestProject.env,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
@@ -4601,91 +4525,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Remap secret_ref bindings in imported agent/project records to target company secret IDs
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "secret" || !envInput.secretName) continue;
const newSecretId = secretNameToId.get(envInput.secretName);
if (!newSecretId) {
// secret wasn't created (decryption failure or error) — it's already a placeholder in the env
continue;
}
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (agentId) {
const agent = await agents.getById(agentId);
if (agent) {
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown> | undefined;
let mutated = false;
if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await agents.update(agentId, { adapterConfig });
}
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (projectId) {
const project = await projects.getById(projectId);
if (project && project.env && typeof project.env === "object") {
const env = project.env as Record<string, unknown>;
let mutated = false;
if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
}
}
// Note: the legacy secret remapping below is kept as a safety net for
// agents/projects that were created/updated before this code existed.
// It can be removed once the inline reconstruction above is stable.
// Reconstruct plain env bindings and fill in missing env keys on imported agents/projects
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue;
if (!envInput.defaultValue && envInput.kind === "plain") continue;
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (!agentId) continue;
const agent = await agents.getById(agentId);
if (!agent) continue;
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = (adapterConfig.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) {
adapterConfig.env = env;
await agents.update(agentId, { adapterConfig });
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (!projectId) continue;
const project = await projects.getById(projectId);
if (!project) continue;
const env = (project.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
if (include.issues) {
const routines = routineService(db);
for (const manifestIssue of sourceManifest.issues) {
File diff suppressed because it is too large Load Diff
-282
View File
@@ -1,282 +0,0 @@
import path from "path";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { Volume, createFsFromVolume } from "memfs";
import { unprocessable } from "../errors.js";
export type ParsedGitSource = {
cloneUrl: string;
hostname: string;
owner: string;
repo: string;
ref: string | null;
basePath: string;
filePath: string | null;
explicitRef: boolean;
};
export type RefResolution = {
pinnedSha: string;
trackingRef: string | null;
};
export type RepoSnapshot = {
sha: string;
listFiles(): Promise<string[]>;
readFile(repoPath: string): Promise<string>;
readFileOptional(repoPath: string): Promise<string | null>;
readBinary(repoPath: string): Promise<Uint8Array>;
};
const SHA_REGEX = /^[0-9a-f]{40}$/i;
export function buildCloneUrl(hostname: string, owner: string, repo: string): string {
return `https://${hostname}/${owner}/${repo}.git`;
}
export function parseGitSourceUrl(rawUrl: string): ParsedGitSource {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw unprocessable("Invalid git source URL");
}
if (url.protocol !== "https:") {
throw unprocessable("Source URL must use HTTPS");
}
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length < 2) {
throw unprocessable("Source URL must include an owner and repository");
}
const owner = segments[0]!;
const repo = segments[1]!.replace(/\.git$/i, "");
// Query-string shape: /{owner}/{repo}?ref=...&path=...
// Used by company portability URLs. Takes precedence over path-based parsing
// so a URL with both shapes (rare) prefers the explicit query params.
const queryRef = url.searchParams.get("ref")?.trim() ?? null;
const queryPath = url.searchParams.get("path")?.trim() ?? null;
if (queryRef || queryPath) {
const normalizedPath = (queryPath ?? "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
return {
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
hostname: url.hostname,
owner,
repo,
ref: queryRef || null,
basePath: normalizedPath,
filePath: null,
explicitRef: Boolean(queryRef),
};
}
let ref: string | null = null;
let basePath = "";
let filePath: string | null = null;
let explicitRef = false;
let tail: string[] = [];
// Recognise common host-specific URL shapes so users can paste a tree/blob link.
if (segments[2] === "tree" || segments[2] === "blob") {
// github.com style
ref = segments[3] ?? null;
tail = segments.slice(4);
explicitRef = ref !== null;
} else if (segments[2] === "src" && (segments[3] === "branch" || segments[3] === "commit" || segments[3] === "tag")) {
// gitea / forgejo style
ref = segments[4] ?? null;
tail = segments.slice(5);
explicitRef = ref !== null;
} else if (segments[2] === "-" && (segments[3] === "tree" || segments[3] === "blob")) {
// gitlab style: /{owner}/{repo}/-/tree/{ref}/{path}
ref = segments[4] ?? null;
tail = segments.slice(5);
explicitRef = ref !== null;
} else if (segments[2] === "src" && segments.length >= 4) {
// bitbucket style: /{owner}/{repo}/src/{ref}/{path}
ref = segments[3] ?? null;
tail = segments.slice(4);
explicitRef = ref !== null;
}
if (segments[2] === "blob" || (segments[2] === "-" && segments[3] === "blob")) {
const joined = tail.join("/");
filePath = joined || null;
basePath = filePath ? path.posix.dirname(filePath) : "";
if (basePath === ".") basePath = "";
} else if (tail.length > 0) {
const joined = tail.join("/");
// Heuristic: if the last segment looks like a file (has an extension), treat as file
const last = tail[tail.length - 1]!;
if (/\.[A-Za-z0-9]+$/.test(last)) {
filePath = joined;
basePath = path.posix.dirname(joined);
if (basePath === ".") basePath = "";
} else {
basePath = joined;
}
}
return {
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
hostname: url.hostname,
owner,
repo,
ref,
basePath,
filePath,
explicitRef,
};
}
function buildAuthCallback(authToken: string | undefined) {
if (!authToken) return undefined;
// Universal pattern: token-as-username works for GitHub PATs (classic and fine-grained),
// GitLab project/personal access tokens, Gitea/Forgejo tokens, and Bitbucket app passwords
// when used over the git smart-HTTP protocol.
return () => ({ username: authToken, password: "x-oauth-basic" });
}
async function withGitErrors<T>(label: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/HTTP Error: 401/i.test(message)) {
throw unprocessable(`${label}: authentication required or token rejected`);
}
if (/HTTP Error: 403/i.test(message)) {
throw unprocessable(`${label}: access forbidden`);
}
if (/HTTP Error: 404/i.test(message) || /repository not found/i.test(message)) {
throw unprocessable(`${label}: repository not found`);
}
if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT/i.test(message)) {
throw unprocessable(`${label}: could not connect to host`);
}
throw unprocessable(`${label}: ${message}`);
}
}
export async function resolveGitRef(
parsed: ParsedGitSource,
authToken?: string,
): Promise<RefResolution> {
const onAuth = buildAuthCallback(authToken);
if (parsed.ref && SHA_REGEX.test(parsed.ref.trim())) {
return {
pinnedSha: parsed.ref.trim().toLowerCase(),
trackingRef: parsed.explicitRef ? parsed.ref.trim() : null,
};
}
const refs = await withGitErrors(`Resolve refs for ${parsed.cloneUrl}`, () =>
git.listServerRefs({
http,
url: parsed.cloneUrl,
onAuth,
symrefs: true,
protocolVersion: 2,
}),
);
const findExact = (fullRef: string) => refs.find((r) => r.ref === fullRef);
if (!parsed.ref) {
const head = refs.find((r) => r.ref === "HEAD");
if (!head?.oid) {
throw unprocessable(`Could not determine default branch for ${parsed.cloneUrl}`);
}
const target = head.target?.replace(/^refs\/heads\//, "") ?? null;
return { pinnedSha: head.oid, trackingRef: target };
}
const wanted = parsed.ref.replace(/^refs\/(heads|tags)\//, "");
const branch = findExact(`refs/heads/${wanted}`);
if (branch?.oid) return { pinnedSha: branch.oid, trackingRef: wanted };
// Prefer the peeled (annotated) tag oid when present, else the tag object oid.
const peeled = findExact(`refs/tags/${wanted}^{}`);
if (peeled?.oid) return { pinnedSha: peeled.oid, trackingRef: wanted };
const tag = findExact(`refs/tags/${wanted}`);
if (tag?.oid) return { pinnedSha: tag.oid, trackingRef: wanted };
throw unprocessable(`Ref '${parsed.ref}' not found in ${parsed.cloneUrl}`);
}
export async function openRepoSnapshot(
parsed: ParsedGitSource,
trackingRef: string | null,
expectedSha: string,
authToken?: string,
): Promise<RepoSnapshot> {
const volume = new Volume();
const fs = createFsFromVolume(volume) as unknown as Parameters<typeof git.clone>[0]["fs"];
const dir = "/repo";
const onAuth = buildAuthCallback(authToken);
await withGitErrors(`Clone ${parsed.cloneUrl}`, async () => {
await git.clone({
fs,
http,
dir,
url: parsed.cloneUrl,
ref: trackingRef ?? expectedSha,
singleBranch: true,
depth: 1,
noCheckout: true,
onAuth,
});
});
// Re-resolve to the actual commit cloned. If upstream moved between resolveGitRef and
// clone, we trust what we cloned (snapshot is self-consistent).
const sha = await git.resolveRef({ fs, dir, ref: "HEAD" });
async function listFiles(): Promise<string[]> {
const out: string[] = [];
await git.walk({
fs,
dir,
trees: [git.TREE({ ref: sha })],
map: async (filepath, entries) => {
if (filepath === ".") return;
const entry = entries?.[0];
if (!entry) return;
const type = await entry.type();
if (type === "blob") {
out.push(filepath);
}
},
});
return out;
}
async function readBinary(repoPath: string): Promise<Uint8Array> {
const normalized = repoPath.replace(/^\/+/, "");
const { blob } = await git.readBlob({ fs, dir, oid: sha, filepath: normalized });
return blob;
}
async function readFile(repoPath: string): Promise<string> {
const blob = await readBinary(repoPath);
return new TextDecoder("utf-8").decode(blob);
}
async function readFileOptional(repoPath: string): Promise<string | null> {
try {
return await readFile(repoPath);
} catch (err) {
// isomorphic-git throws NotFoundError when the path is missing from the tree.
const name = (err as { code?: string; name?: string } | null)?.code
?? (err as { name?: string } | null)?.name
?? "";
if (/NotFound/i.test(name)) return null;
throw err;
}
}
return { sha, listFiles, readFile, readFileOptional, readBinary };
}
+25
View File
@@ -0,0 +1,25 @@
import { unprocessable } from "../errors.js";
function isGitHubDotCom(hostname: string) {
const h = hostname.toLowerCase();
return h === "github.com" || h === "www.github.com";
}
export function gitHubApiBase(hostname: string) {
return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`;
}
export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) {
const p = filePath.replace(/^\/+/, "");
return isGitHubDotCom(hostname)
? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(url, init);
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
}
+103 -2
View File
@@ -29,6 +29,8 @@ import {
activityLog,
approvals,
companySkills as companySkillsTable,
documentAnnotationComments,
documentAnnotationThreads,
documentRevisions,
issueDocuments,
heartbeatRunEvents,
@@ -87,6 +89,7 @@ import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
ensurePersistedExecutionWorkspaceAvailable,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
@@ -594,6 +597,8 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
createdByRuntime: boolean;
configSnapshot: Record<string, unknown> | null;
shouldReuseExisting: boolean;
baseRef: string | null | undefined;
baseRefSha: string | null | undefined;
}) {
const base = {
...(input.existingMetadata ?? {}),
@@ -601,6 +606,17 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
createdByRuntime: input.createdByRuntime,
} as Record<string, unknown>;
const existingSnapshot = parseObject(base.baseRefSnapshot);
if (
typeof existingSnapshot.resolvedSha !== "string"
&& input.baseRefSha
) {
base.baseRefSnapshot = {
baseRef: input.baseRef ?? null,
resolvedSha: input.baseRefSha,
};
}
if (input.shouldReuseExisting || !input.configSnapshot) {
return base;
}
@@ -624,6 +640,8 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
}
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
const baseRefSnapshot = parseObject(input.workspace.metadata?.baseRefSnapshot);
const baseRefSha = typeof baseRefSnapshot.resolvedSha === "string" ? baseRefSnapshot.resolvedSha : null;
return {
baseCwd: input.base.baseCwd,
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
@@ -637,6 +655,7 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
warnings: [],
created: false,
baseRefSha,
};
}
@@ -1964,6 +1983,7 @@ async function buildPaperclipWakePayload(input: {
}) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot);
const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId);
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const continuationSummary = input.continuationSummary ?? null;
const issueSummary =
@@ -2054,6 +2074,57 @@ async function buildPaperclipWakePayload(input: {
});
}
const annotationDeltas = annotationCommentId
? await input.db
.select({
id: documentAnnotationComments.id,
issueId: documentAnnotationComments.issueId,
threadId: documentAnnotationComments.threadId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdAt: documentAnnotationComments.createdAt,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
anchorState: documentAnnotationThreads.anchorState,
anchorConfidence: documentAnnotationThreads.anchorConfidence,
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
selectedText: documentAnnotationThreads.selectedText,
prefixText: documentAnnotationThreads.prefixText,
suffixText: documentAnnotationThreads.suffixText,
})
.from(documentAnnotationComments)
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.id, annotationCommentId),
))
.then((rows) => rows.map((row) => ({
id: row.id,
issueId: row.issueId,
threadId: row.threadId,
documentKey: row.documentKey,
revisionNumber: row.currentRevisionNumber,
quote: row.selectedText,
prefix: row.prefixText,
suffix: row.suffixText,
threadStatus: row.status,
anchorState: row.anchorState,
anchorConfidence: row.anchorConfidence,
body: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS
? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS)
: row.body,
bodyTruncated: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
: row.authorUserId
? { type: "user", id: row.authorUserId }
: { type: row.authorType, id: null },
})))
: [];
return {
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
issue: issueSummary
@@ -2111,6 +2182,7 @@ async function buildPaperclipWakePayload(input: {
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,
annotationDeltas,
commentWindow: {
requestedCount: commentIds.length,
includedCount: comments.length,
@@ -4063,7 +4135,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
continuationAttempt: decision.nextAttempt,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, continuationRun.id));
.where(eq(heartbeatRuns.id, run.id));
}
}
@@ -7229,7 +7301,34 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
repoRef: resolvedWorkspace.repoRef,
} satisfies ExecutionWorkspaceInput;
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? buildRealizedExecutionWorkspaceFromPersisted({
? await ensurePersistedExecutionWorkspaceAvailable({
base: executionWorkspaceBase,
workspace: {
mode: existingExecutionWorkspace.mode,
strategyType: existingExecutionWorkspace.strategyType,
cwd: existingExecutionWorkspace.cwd,
providerRef: existingExecutionWorkspace.providerRef,
projectId: existingExecutionWorkspace.projectId,
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
repoUrl: existingExecutionWorkspace.repoUrl,
baseRef: existingExecutionWorkspace.baseRef,
branchName: existingExecutionWorkspace.branchName,
metadata: existingExecutionWorkspace.metadata as Record<string, unknown> | null,
config: {
provisionCommand:
existingExecutionWorkspace.config?.provisionCommand
?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand
?? null,
},
},
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
}) ?? buildRealizedExecutionWorkspaceFromPersisted({
base: executionWorkspaceBase,
workspace: existingExecutionWorkspace,
})
@@ -7254,6 +7353,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
createdByRuntime: executionWorkspace.created,
configSnapshot,
shouldReuseExisting,
baseRef: executionWorkspace.repoRef,
baseRefSha: executionWorkspace.baseRefSha ?? null,
});
try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
+12
View File
@@ -0,0 +1,12 @@
export function normalizePortablePath(input: string) {
const parts: string[] = [];
for (const segment of input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "").split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(segment);
}
return parts.join("/");
}
+201
View File
@@ -0,0 +1,201 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
CatalogSkill,
CatalogSkillFileDetail,
CatalogSkillListQuery,
} from "@paperclipai/shared";
import { HttpError, conflict, notFound } from "../errors.js";
import { normalizePortablePath } from "./portable-path.js";
interface CatalogManifestFile {
packageName: string;
packageVersion: string;
skills: CatalogSkill[];
}
const serviceDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(serviceDir, "../../..");
const catalogPackageRoot = path.join(repoRoot, "packages/skills-catalog");
const catalogManifestPath = path.join(catalogPackageRoot, "generated/catalog.json");
let cachedCatalogManifest: {
manifest: CatalogManifestFile;
mtimeMs: number;
size: number;
} | null = null;
function loadCatalogManifest(): CatalogManifestFile {
if (!existsSync(catalogManifestPath)) {
throw new Error(
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
);
}
return JSON.parse(readFileSync(catalogManifestPath, "utf8")) as CatalogManifestFile;
}
function getCatalogManifest() {
if (!existsSync(catalogManifestPath)) {
throw new Error(
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
);
}
const stats = statSync(catalogManifestPath);
if (
cachedCatalogManifest &&
cachedCatalogManifest.mtimeMs === stats.mtimeMs &&
cachedCatalogManifest.size === stats.size
) {
return cachedCatalogManifest.manifest;
}
const manifest = loadCatalogManifest();
cachedCatalogManifest = {
manifest,
mtimeMs: stats.mtimeMs,
size: stats.size,
};
return manifest;
}
function getCatalogSkills() {
const catalogManifest = getCatalogManifest();
return catalogManifest.skills.map((skill) => ({
...skill,
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
}));
}
function isMarkdownPath(filePath: string) {
const fileName = path.posix.basename(filePath).toLowerCase();
return fileName === "skill.md" || fileName.endsWith(".md");
}
function inferLanguageFromPath(filePath: string) {
const fileName = path.posix.basename(filePath).toLowerCase();
if (fileName === "skill.md" || fileName.endsWith(".md")) return "markdown";
if (fileName.endsWith(".ts")) return "typescript";
if (fileName.endsWith(".tsx")) return "tsx";
if (fileName.endsWith(".js")) return "javascript";
if (fileName.endsWith(".jsx")) return "jsx";
if (fileName.endsWith(".json")) return "json";
if (fileName.endsWith(".yml") || fileName.endsWith(".yaml")) return "yaml";
if (fileName.endsWith(".sh")) return "bash";
if (fileName.endsWith(".py")) return "python";
if (fileName.endsWith(".html")) return "html";
if (fileName.endsWith(".css")) return "css";
return null;
}
function resolveCatalogPackageRoot() {
return catalogPackageRoot;
}
function searchText(skill: CatalogSkill) {
return [
skill.id,
skill.key,
skill.slug,
skill.name,
skill.description,
skill.category,
skill.kind,
...skill.recommendedForRoles,
...skill.tags,
].join("\n").toLowerCase();
}
export function listCatalogSkills(query: CatalogSkillListQuery = {}): CatalogSkill[] {
const normalizedQuery = query.q?.trim().toLowerCase() ?? "";
return getCatalogSkills()
.filter((skill) => !query.kind || skill.kind === query.kind)
.filter((skill) => !query.category || skill.category === query.category)
.filter((skill) => !normalizedQuery || searchText(skill).includes(normalizedQuery))
.sort((left, right) => left.name.localeCompare(right.name) || left.key.localeCompare(right.key));
}
export function resolveCatalogSkillReference(reference: string): { skill: CatalogSkill | null; ambiguous: boolean } {
const trimmed = reference.trim();
if (!trimmed) return { skill: null, ambiguous: false };
const catalogSkills = getCatalogSkills();
const exact = catalogSkills.find((skill) => skill.id === trimmed || skill.key === trimmed);
if (exact) return { skill: exact, ambiguous: false };
const slugMatches = catalogSkills.filter((skill) => skill.slug === trimmed);
if (slugMatches.length === 1) return { skill: slugMatches[0]!, ambiguous: false };
if (slugMatches.length > 1) return { skill: null, ambiguous: true };
return { skill: null, ambiguous: false };
}
export function getCatalogSkillOrThrow(reference: string): CatalogSkill {
const result = resolveCatalogSkillReference(reference);
if (result.ambiguous) {
throw conflict(`Catalog skill slug "${reference}" is ambiguous. Use an id or key.`);
}
if (!result.skill) {
throw notFound("Catalog skill not found");
}
return result.skill;
}
export async function readCatalogSkillFile(
reference: string,
relativePath = "SKILL.md",
): Promise<CatalogSkillFileDetail> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
if (fileEntry.kind === "asset") {
throw new HttpError(415, "Catalog asset previews are not supported.");
}
const content = await fs.readFile(absolutePath, "utf8");
return {
catalogSkillId: skill.id,
path: normalizedPath,
kind: fileEntry.kind,
content,
language: inferLanguageFromPath(normalizedPath),
markdown: isMarkdownPath(normalizedPath),
};
}
export async function copyCatalogSkillFile(reference: string, relativePath: string, targetPath: string): Promise<void> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
await fs.copyFile(absolutePath, targetPath);
}
export function getCatalogPackageMetadata() {
const catalogManifest = getCatalogManifest();
return {
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
};
}
+165 -8
View File
@@ -67,6 +67,7 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
worktreePath: string | null;
warnings: string[];
created: boolean;
baseRefSha?: string | null;
}
export interface RuntimeServiceRef {
@@ -524,11 +525,110 @@ async function runGit(args: string[], cwd: string): Promise<string> {
return proc.stdout.trim();
}
function formatShortSha(value: string | null | undefined) {
return value ? value.slice(0, 12) : "unknown";
}
function gitErrorIncludes(error: unknown, needle: string) {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes(needle.toLowerCase());
}
function parseRemoteTrackingRef(ref: string): { remote: string; branch: string } | null {
const trimmed = ref.trim();
const refsRemotesPrefix = "refs/remotes/";
const normalized = trimmed.startsWith(refsRemotesPrefix)
? trimmed.slice(refsRemotesPrefix.length)
: trimmed;
const slashIndex = normalized.indexOf("/");
if (slashIndex <= 0 || slashIndex === normalized.length - 1) return null;
const remote = normalized.slice(0, slashIndex);
const branch = normalized.slice(slashIndex + 1);
if (!/^[A-Za-z0-9._-]+$/.test(remote)) return null;
return { remote, branch };
}
async function refreshRemoteTrackingBaseRef(repoRoot: string, baseRef: string): Promise<string[]> {
const remoteTracking = parseRemoteTrackingRef(baseRef);
if (!remoteTracking) return [];
const remoteExists = await runGit(["remote", "get-url", remoteTracking.remote], repoRoot)
.then(() => true)
.catch(() => false);
if (!remoteExists) return [];
try {
await runGit([
"fetch",
"--prune",
remoteTracking.remote,
`+refs/heads/${remoteTracking.branch}:refs/remotes/${remoteTracking.remote}/${remoteTracking.branch}`,
], repoRoot);
return [];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`Could not refresh base ref ${baseRef} before preparing the execution workspace: ${message}`];
}
}
async function resolveBaseRefSha(repoRoot: string, baseRef: string): Promise<string | null> {
return await runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], repoRoot).catch(() => null);
}
function readRecordedBaseRefSha(metadata: Record<string, unknown> | null | undefined): string | null {
const snapshot = parseObject(metadata?.baseRefSnapshot);
const resolvedSha = snapshot.resolvedSha;
return typeof resolvedSha === "string" && resolvedSha.trim().length > 0 ? resolvedSha.trim() : null;
}
export async function inspectExecutionWorkspaceBaseDrift(input: {
repoRoot: string;
worktreePath: string;
branchName: string | null;
baseRef: string | null;
recordedBaseRefSha?: string | null;
skipRefresh?: boolean;
}): Promise<{
warnings: string[];
currentBaseRefSha: string | null;
branchBaseRefSha: string | null;
}> {
const baseRef = input.baseRef?.trim();
if (!baseRef) {
return { warnings: [], currentBaseRefSha: null, branchBaseRefSha: null };
}
const warnings = input.skipRefresh ? [] : await refreshRemoteTrackingBaseRef(input.repoRoot, baseRef);
const currentBaseRefSha = await resolveBaseRefSha(input.repoRoot, baseRef);
if (!currentBaseRefSha) {
warnings.push(`Could not resolve base ref ${baseRef} while checking execution workspace freshness.`);
return { warnings, currentBaseRefSha: null, branchBaseRefSha: null };
}
const branchBaseRefSha = await runGit(["merge-base", "HEAD", baseRef], input.worktreePath).catch(() => null);
if (!branchBaseRefSha) {
warnings.push(`Could not compare execution workspace ${input.branchName ?? "branch"} against base ref ${baseRef}.`);
return { warnings, currentBaseRefSha, branchBaseRefSha: null };
}
if (branchBaseRefSha !== currentBaseRefSha) {
const behindCountRaw = await runGit(["rev-list", "--count", `HEAD..${baseRef}`], input.worktreePath).catch(() => "");
const behindCount = Number.parseInt(behindCountRaw, 10);
const behindText = Number.isFinite(behindCount) && behindCount > 0
? `${behindCount} commit${behindCount === 1 ? "" : "s"}`
: "newer commits";
const recordedText = input.recordedBaseRefSha
? `recorded base ${formatShortSha(input.recordedBaseRefSha)}`
: `merge-base ${formatShortSha(branchBaseRefSha)}`;
warnings.push(
`Execution workspace branch ${input.branchName ? `"${input.branchName}"` : "HEAD"} is behind ${baseRef} by ${behindText}: ${recordedText}, current base ${formatShortSha(currentBaseRefSha)}. Refresh or rebase the workspace before relying on recent base-branch fixes.`,
);
}
return { warnings, currentBaseRefSha, branchBaseRefSha };
}
type GitWorktreeListEntry = {
worktree: string;
branch: string | null;
@@ -591,22 +691,31 @@ async function isGitCheckout(cwd: string): Promise<boolean> {
}
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
const originMasterRef = "origin/master";
await refreshRemoteTrackingBaseRef(repoRoot, originMasterRef);
if (await resolveBaseRefSha(repoRoot, originMasterRef)) {
return originMasterRef;
}
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
try {
const remoteHead = await runGit(
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
repoRoot,
);
const branch = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead;
if (branch) return branch;
if (remoteHead) {
await refreshRemoteTrackingBaseRef(repoRoot, remoteHead);
if (await resolveBaseRefSha(repoRoot, remoteHead)) return remoteHead;
}
} catch {
// Not set — fall through to heuristic
}
// Fallback: check for common default branch names on the remote
for (const candidate of ["main", "master"]) {
for (const candidate of ["origin/master", "origin/main", "main", "master"]) {
try {
await runGit(["rev-parse", "--verify", `refs/remotes/origin/${candidate}`], repoRoot);
await refreshRemoteTrackingBaseRef(repoRoot, candidate);
await runGit(["rev-parse", "--verify", `${candidate}^{commit}`], repoRoot);
return candidate;
} catch {
// Not found — try next
@@ -1003,6 +1112,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath: null,
warnings: [],
created: false,
baseRefSha: null,
};
}
@@ -1026,10 +1136,20 @@ export async function realizeExecutionWorkspace(input: {
const baseRef = configuredBaseRef
?? await detectDefaultBranch(repoRoot)
?? "HEAD";
const baseRefreshWarnings = await refreshRemoteTrackingBaseRef(repoRoot, baseRef);
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
await fs.mkdir(worktreeParentDir, { recursive: true });
async function reuseExistingWorktree(reusablePath: string) {
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath: reusablePath,
branchName,
baseRef,
recordedBaseRefSha: null,
skipRefresh: true,
});
if (input.recorder) {
await input.recorder.recordOperation({
phase: "worktree_prepare",
@@ -1039,6 +1159,8 @@ export async function realizeExecutionWorkspace(input: {
worktreePath: reusablePath,
branchName,
baseRef,
currentBaseRefSha: baseDrift.currentBaseRefSha,
branchBaseRefSha: baseDrift.branchBaseRefSha,
created: false,
reused: true,
},
@@ -1066,8 +1188,9 @@ export async function realizeExecutionWorkspace(input: {
cwd: reusablePath,
branchName,
worktreePath: reusablePath,
warnings: [],
warnings: [...baseRefreshWarnings, ...baseDrift.warnings],
created: false,
baseRefSha: baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
};
}
@@ -1109,6 +1232,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: currentBaseRefSha,
created: true,
},
successMessage: `Created git worktree at ${worktreePath}\n`,
@@ -1128,6 +1252,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: currentBaseRefSha,
created: false,
reusedExistingBranch: true,
},
@@ -1163,8 +1288,9 @@ export async function realizeExecutionWorkspace(input: {
cwd: worktreePath,
branchName,
worktreePath,
warnings: [],
warnings: baseRefreshWarnings,
created: true,
baseRefSha: currentBaseRefSha,
};
}
@@ -1180,6 +1306,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
repoUrl: string | null | undefined;
baseRef: string | null | undefined;
branchName: string | null | undefined;
metadata?: Record<string, unknown> | null;
config?: {
provisionCommand?: string | null;
} | null;
@@ -1205,15 +1332,26 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
warnings: [],
created: false,
baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
};
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
if (strategy !== "git_worktree") {
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
if (await directoryExists(cwd)) {
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath: realized.worktreePath ?? cwd,
branchName: realized.branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
recordedBaseRefSha,
});
realized.warnings = baseDrift.warnings;
realized.baseRefSha = recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
if (provisionCommand) {
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
@@ -1232,7 +1370,6 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const worktreePath = realized.worktreePath ?? cwd;
const branchName = asString(input.workspace.branchName, "").trim();
if (!branchName) {
@@ -1241,6 +1378,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
const restoreBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
const restoreRefreshWarnings = restoreBaseRef ? await refreshRemoteTrackingBaseRef(repoRoot, restoreBaseRef) : [];
const restoreCurrentBaseRefSha = restoreBaseRef ? await resolveBaseRefSha(repoRoot, restoreBaseRef) : null;
let created = false;
try {
@@ -1253,6 +1393,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath,
branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
currentBaseRefSha: restoreCurrentBaseRefSha,
created: false,
restored: true,
},
@@ -1268,6 +1409,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
throw error;
}
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
@@ -1277,6 +1419,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: recreatedBaseRefSha,
created: true,
restored: true,
},
@@ -1286,6 +1429,15 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
created = true;
}
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath,
branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
recordedBaseRefSha,
skipRefresh: true,
});
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
@@ -1305,7 +1457,12 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
...realized,
cwd: worktreePath,
worktreePath,
warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
created,
baseRefSha:
recordedBaseRefSha
?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
?? baseDrift.currentBaseRefSha,
};
}