Refine company package export format

This commit is contained in:
Dotta
2026-03-14 09:46:16 -05:00
parent 56a34a8f8a
commit 1d8f514d10
13 changed files with 1684 additions and 304 deletions

View File

@@ -32,6 +32,9 @@ interface CompanyDeleteOptions extends BaseClientOptions {
interface CompanyExportOptions extends BaseClientOptions {
out?: string;
include?: string;
projects?: string;
issues?: string;
projectIssues?: string;
}
interface CompanyImportOptions extends BaseClientOptions {
@@ -54,14 +57,16 @@ function normalizeSelector(input: string): string {
}
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
if (!input || !input.trim()) return { company: true, agents: true };
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false };
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
const include = {
company: values.includes("company"),
agents: values.includes("agents"),
projects: values.includes("projects"),
issues: values.includes("issues"),
};
if (!include.company && !include.agents) {
throw new Error("Invalid --include value. Use one or both of: company,agents");
if (!include.company && !include.agents && !include.projects && !include.issues) {
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues");
}
return include;
}
@@ -75,6 +80,11 @@ function parseAgents(input: string | undefined): "all" | string[] {
return Array.from(new Set(values));
}
function parseCsvValues(input: string | undefined): string[] {
if (!input || !input.trim()) return [];
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
}
function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input.trim());
}
@@ -92,7 +102,10 @@ async function collectPackageFiles(root: string, current: string, files: Record<
await collectPackageFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
if (!entry.isFile()) continue;
const isMarkdown = entry.name.endsWith(".md");
const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml";
if (!isMarkdown && !isPaperclipYaml) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = await readFile(absolutePath, "utf8");
}
@@ -261,14 +274,22 @@ export function registerCompanyCommands(program: Command): void {
.description("Export a company into a portable markdown package")
.argument("<companyId>", "Company ID")
.requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents")
.option("--projects <values>", "Comma-separated project shortnames/ids to export")
.option("--issues <values>", "Comma-separated issue identifiers/ids to export")
.option("--project-issues <values>", "Comma-separated project shortnames/ids whose issues should be exported")
.action(async (companyId: string, opts: CompanyExportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const include = parseInclude(opts.include);
const exported = await ctx.api.post<CompanyPortabilityExportResult>(
`/api/companies/${companyId}/export`,
{ include },
{
include,
projects: parseCsvValues(opts.projects),
issues: parseCsvValues(opts.issues),
projectIssues: parseCsvValues(opts.projectIssues),
},
);
if (!exported) {
throw new Error("Export request returned no data");
@@ -280,6 +301,7 @@ export function registerCompanyCommands(program: Command): void {
out: path.resolve(opts.out!),
rootPath: exported.rootPath,
filesWritten: Object.keys(exported.files).length,
paperclipExtensionPath: exported.paperclipExtensionPath,
warningCount: exported.warnings.length,
},
{ json: ctx.json },
@@ -300,7 +322,7 @@ export function registerCompanyCommands(program: Command): void {
.command("import")
.description("Import a portable markdown company package from local path, URL, or GitHub")
.requiredOption("--from <pathOrUrl>", "Source path or URL")
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents")
.option("--target <mode>", "Target mode: new | existing")
.option("-C, --company-id <id>", "Existing target company ID")
.option("--new-company-name <name>", "Name override for --target new")

View File

@@ -810,20 +810,27 @@ V1 is complete only when all criteria are true:
V1 supports company import/export using a portable package contract:
- exactly one JSON entrypoint: `paperclip.manifest.json`
- all other package files are markdown with frontmatter
- agent convention:
- `agents/<slug>/AGENTS.md` (required for V1 export/import)
- `agents/<slug>/HEARTBEAT.md` (optional, import accepted)
- `agents/<slug>/*.md` (optional, import accepted)
- markdown-first package rooted at `COMPANY.md`
- implicit folder discovery by convention
- `.paperclip.yaml` sidecar for Paperclip-specific fidelity
- canonical base package is vendor-neutral and aligned with `docs/companies/companies-spec.md`
- common conventions:
- `agents/<slug>/AGENTS.md`
- `teams/<slug>/TEAM.md`
- `projects/<slug>/PROJECT.md`
- `projects/<slug>/tasks/<slug>/TASK.md`
- `tasks/<slug>/TASK.md`
- `skills/<slug>/SKILL.md`
Export/import behavior in V1:
- export includes company metadata and/or agents based on selection
- export strips environment-specific paths (`cwd`, local instruction file paths)
- export never includes secret values; secret requirements are reported
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
- projects and starter tasks are opt-in export content rather than default package content
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication)
- export never includes secret values; env inputs are reported as portable declarations instead
- import supports target modes:
- create a new company
- import into an existing company
- import supports collision strategies: `rename`, `skip`, `replace`
- import supports preview (dry-run) before apply
- GitHub imports warn on unpinned refs instead of blocking

View File

@@ -26,24 +26,26 @@ This plan is about implementation and rollout inside Paperclip.
## 2. Executive Summary
Paperclip already has a V1 portability feature:
Paperclip already has portability primitives in the repo:
- server import/export/preview APIs
- CLI import/export commands
- a `paperclip.manifest.json` plus markdown payload format
- company metadata + agent portability only
- shared portability types and validators
That is useful, but it is not the right long-term authoring format.
Those primitives are being cut over to the new package model rather than extended for backward compatibility.
The new direction is:
1. markdown-first package authoring
2. GitHub repo or local folder as the default source of truth
3. the company package model is explicitly an extension of Agent Skills
4. no future dependency on `paperclip.manifest.json`
5. package graph resolution at import time
6. entity-level import UI with dependency-aware tree selection
7. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it
3. a vendor-neutral base package spec for agent-company runtimes, not just Paperclip
4. the company package model is explicitly an extension of Agent Skills
5. no future dependency on `paperclip.manifest.json`
6. implicit folder discovery by convention for the common case
7. an always-emitted `.paperclip.yaml` sidecar for high-fidelity Paperclip-specific details
8. package graph resolution at import time
9. entity-level import UI with dependency-aware tree selection
10. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it
## 3. Product Goals
@@ -55,6 +57,7 @@ The new direction is:
- company definition
- org subtree / team definition
- agent definitions
- optional starter projects and tasks
- reusable skills
- A user can import into:
- a new company
@@ -66,6 +69,7 @@ The new direction is:
- what is referenced externally
- what needs secrets or approvals
- Export preserves attribution, licensing, and pinned upstream references.
- Export produces a clean vendor-neutral package plus a Paperclip sidecar.
- `companies.sh` can later act as a discovery/index layer over repos implementing this format.
### 3.2 Non-Goals
@@ -92,11 +96,11 @@ Current implementation exists here:
Current product limitations:
1. Portability model is only `company` + `agents`.
2. Current import/export contract is JSON-entrypoint-first.
3. UI API methods exist but there is no real Company Settings import/export UX.
4. Import is lossy relative to export.
5. The current markdown frontmatter parser is too primitive for the richer package model.
1. Import/export UX still needs deeper tree-selection and skill/package management polish.
2. Adapter-specific skill sync remains uneven across adapters and must degrade cleanly when unsupported.
3. Projects and starter tasks should stay opt-in on export rather than default package content.
4. Import/export still needs stronger coverage around attribution, pin verification, and executable-package warnings.
5. The current markdown frontmatter parser is intentionally lightweight and should stay constrained to the documented shape.
## 5. Canonical Package Direction
@@ -107,6 +111,8 @@ The canonical authoring format becomes a markdown-first package rooted in one of
- `COMPANY.md`
- `TEAM.md`
- `AGENTS.md`
- `PROJECT.md`
- `TASK.md`
- `SKILL.md`
The normative draft is:
@@ -121,10 +127,24 @@ Rules:
- `SKILL.md` stays Agent Skills compatible
- the company package model is an extension of Agent Skills
- Paperclip-specific extensions live under metadata
- the base package is vendor-neutral and intended for any agent-company runtime
- Paperclip-specific fidelity lives in `.paperclip.yaml`
- Paperclip may resolve and install `SKILL.md` packages, but it must not require a Paperclip-only skill format
### 5.3 Relationship To Current V1 Manifest
### 5.3 Base Package Vs Paperclip Extension
The repo format should have two layers:
- base package:
- minimal, readable, social, vendor-neutral
- implicit folder discovery by convention
- no Paperclip-only runtime fields by default
- Paperclip extension:
- `.paperclip.yaml`
- adapter/runtime/permissions/budget/workspace fidelity
- emitted by Paperclip tools as a sidecar while the base package stays readable
### 5.4 Relationship To Current V1 Manifest
`paperclip.manifest.json` is not part of the future package direction.
@@ -143,13 +163,9 @@ Paperclip import/export should support these entity kinds:
- company
- team
- agent
- skill
Future optional kinds:
- project
- goal
- seed task bundle
- task
- skill
### 6.2 Team Semantics
@@ -179,6 +195,7 @@ Examples:
- selecting an agent auto-selects its required docs and skill refs
- selecting a team auto-selects its subtree
- selecting a company auto-selects all included entities by default
- selecting a project auto-selects its starter tasks
The preview output should reflect graph resolution explicitly.
@@ -272,7 +289,7 @@ Every import preview should surface:
- referenced external content
- missing files
- hash mismatch or pinning issues
- required secrets
- env inputs, including required vs optional and default values when present
- unsupported content types
- trust/licensing warnings
@@ -342,21 +359,34 @@ Exports should:
- omit timestamps and counters unless explicitly needed
- omit secret values
- omit local absolute paths
- omit duplicated inline prompt content from `.paperclip.yaml` when `AGENTS.md` already carries the instructions
- preserve references and attribution
- emit `.paperclip.yaml` alongside the base package
- express adapter env/secrets as portable env input declarations rather than exported secret binding ids
- preserve compatible `SKILL.md` content as-is
### 9.3 Export Modes
Projects and issues should not be exported by default.
Initial export modes:
They should be opt-in through selectors such as:
- `--projects project-shortname-1,project-shortname-2`
- `--issues PAP-1,PAP-3`
- `--project-issues project-shortname-1,project-shortname-2`
This supports “clean public company package” workflows where a maintainer exports a follower-facing company package without bundling active work items every time.
### 9.3 Export Units
Initial export units:
- company package
- team package
- single agent package
Later optional modes:
Later optional units:
- skill pack export
- seed projects/goals bundle
- seed projects/tasks bundle
## 10. Storage Model Inside Paperclip

View File

@@ -1,17 +1,19 @@
# Company Packages Specification
# Agent Companies Specification
Extension of the Agent Skills Specification
Version: `0.1-draft`
Version: `agentcompanies/v1-draft`
## 1. Purpose
A Company Package is a filesystem- and GitHub-native format for describing a company, team, agent, and associated skills using markdown files with YAML frontmatter.
An Agent Company package is a filesystem- and GitHub-native format for describing a company, team, agent, project, task, and associated skills using markdown files with YAML frontmatter.
This specification is an extension of the Agent Skills specification, not a replacement for it.
It defines how company-, team-, and agent-level package structure composes around the existing `SKILL.md` model.
This specification is vendor-neutral. It is intended to be usable by any agent-company runtime, not only Paperclip.
The format is designed to:
- be readable and writable by humans
@@ -30,6 +32,8 @@ The format is designed to:
5. External references must be pinnable to immutable Git commits.
6. Attribution and license metadata must survive import/export.
7. Slugs and relative paths are the portable identity layer, not database ids.
8. Conventional folder structure should work without verbose wiring.
9. Vendor-specific fidelity belongs in optional extensions, not the base package.
## 3. Package Kinds
@@ -38,6 +42,8 @@ A package root is identified by one primary markdown file:
- `COMPANY.md` for a company package
- `TEAM.md` for a team package
- `AGENTS.md` for an agent package
- `PROJECT.md` for a project package
- `TASK.md` for a task package
- `SKILL.md` for a skill package defined by the Agent Skills specification
A GitHub repo may contain one package at root or many packages in subdirectories.
@@ -50,11 +56,17 @@ Common conventions:
COMPANY.md
TEAM.md
AGENTS.md
PROJECT.md
TASK.md
SKILL.md
agents/<slug>/AGENTS.md
teams/<slug>/TEAM.md
projects/<slug>/PROJECT.md
projects/<slug>/tasks/<slug>/TASK.md
tasks/<slug>/TASK.md
skills/<slug>/SKILL.md
.paperclip.yaml
HEARTBEAT.md
SOUL.md
@@ -73,11 +85,11 @@ Rules:
## 5. Common Frontmatter
All package root docs should support these fields:
Package docs may support these fields:
```yaml
schema: company-packages/v0.1
kind: company | team | agent
schema: agentcompanies/v1
kind: company | team | agent | project | task
slug: my-slug
name: Human Readable Name
description: Short description
@@ -95,11 +107,12 @@ sources: []
Notes:
- `schema` is required for `COMPANY.md`, `TEAM.md`, and `AGENTS.md`
- `kind` is required
- `schema` is optional and should usually appear only at the package root
- `kind` is optional when file path and file name already make the kind obvious
- `slug` should be URL-safe and stable
- `sources` is for provenance and external references
- `metadata` is for tool-specific extensions
- exporters should omit empty or default-valued fields
## 6. COMPANY.md
@@ -108,11 +121,10 @@ Notes:
### Required fields
```yaml
schema: company-packages/v0.1
kind: company
slug: lean-dev-shop
name: Lean Dev Shop
description: Small engineering-focused AI company
slug: lean-dev-shop
schema: agentcompanies/v1
```
### Recommended fields
@@ -122,15 +134,10 @@ version: 1.0.0
license: MIT
authors:
- name: Example Org
brandColor: "#22c55e"
goals:
- Build and ship software products
defaults:
requireBoardApprovalForNewAgents: true
includes:
- path: agents/ceo/AGENTS.md
- path: teams/engineering/TEAM.md
- path: skills/review/SKILL.md
- https://github.com/example/shared-company-parts/blob/0123456789abcdef0123456789abcdef01234567/teams/engineering/TEAM.md
requirements:
secrets:
- OPENAI_API_KEY
@@ -139,8 +146,10 @@ requirements:
### Semantics
- `includes` defines the package graph
- local package contents should be discovered implicitly by folder convention
- `includes` is optional and should be used mainly for external refs or nonstandard locations
- included items may be local or external references
- `COMPANY.md` may include agents directly, teams, or skills
- `COMPANY.md` may include agents directly, teams, projects, tasks, or skills
- a company importer may render `includes` as the tree/checkbox import UI
## 7. TEAM.md
@@ -150,17 +159,15 @@ requirements:
### Example
```yaml
schema: company-packages/v0.1
kind: team
slug: engineering
name: Engineering
description: Product and platform engineering team
manager:
path: ../cto/AGENTS.md
schema: agentcompanies/v1
slug: engineering
manager: ../cto/AGENTS.md
includes:
- path: ../platform-lead/AGENTS.md
- path: ../frontend-lead/AGENTS.md
- path: ../../skills/review/SKILL.md
- ../platform-lead/AGENTS.md
- ../frontend-lead/AGENTS.md
- ../../skills/review/SKILL.md
tags:
- team
- engineering
@@ -180,37 +187,11 @@ tags:
### Example
```yaml
schema: company-packages/v0.1
kind: agent
slug: ceo
name: CEO
role: ceo
title: Chief Executive Officer
description: Sets strategy and manages executives
icon: crown
capabilities:
- strategy
- delegation
reportsTo: null
adapter:
type: codex_local
config:
model: gpt-5
runtime:
heartbeat:
intervalSec: 3600
permissions:
canCreateAgents: true
skills:
- path: ../../skills/plan-ceo-review/SKILL.md
docs:
instructions: AGENTS.md
heartbeat: HEARTBEAT.md
soul: SOUL.md
requirements:
secrets:
- OPENAI_API_KEY
metadata: {}
- ../../skills/plan-ceo-review/SKILL.md
```
### Semantics
@@ -218,10 +199,111 @@ metadata: {}
- body content is the canonical default instruction content for the agent
- `docs` points to sibling markdown docs when present
- `skills` references reusable `SKILL.md` packages
- `adapter.config` and `runtime` should contain only portable values
- vendor-specific adapter/runtime config should not live in the base package
- local absolute paths, machine-specific cwd values, and secret values must not be exported as canonical package data
## 9. SKILL.md Compatibility
## 9. PROJECT.md
`PROJECT.md` defines a lightweight project package.
### Example
```yaml
name: Q2 Launch
description: Ship the Q2 launch plan and supporting assets
owner: cto
```
### Semantics
- a project package groups related starter tasks and supporting markdown
- `owner` should reference an agent slug when there is a clear project owner
- a conventional `tasks/` subfolder should be discovered implicitly
- `includes` may contain `TASK.md`, `SKILL.md`, or supporting docs when explicit wiring is needed
- project packages are intended to seed planned work, not represent runtime task state
## 10. TASK.md
`TASK.md` defines a lightweight starter task.
### Example
```yaml
name: Monday Review
assignee: ceo
project: q2-launch
schedule:
timezone: America/Chicago
startsAt: 2026-03-16T09:00:00-05:00
recurrence:
frequency: weekly
interval: 1
weekdays:
- monday
time:
hour: 9
minute: 0
```
### Semantics
- body content is the canonical markdown task description
- `assignee` should reference an agent slug inside the package
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
### Scheduling
The scheduling model is intentionally lightweight. It should cover common recurring patterns such as:
- every 6 hours
- every weekday at 9:00
- every Monday morning
- every month on the 1st
- every first Monday of the month
- every year on January 1
Suggested shape:
```yaml
schedule:
timezone: America/Chicago
startsAt: 2026-03-14T09:00:00-05:00
recurrence:
frequency: hourly | daily | weekly | monthly | yearly
interval: 1
weekdays:
- monday
- wednesday
monthDays:
- 1
- 15
ordinalWeekdays:
- weekday: monday
ordinal: 1
months:
- 1
- 6
time:
hour: 9
minute: 0
until: 2026-12-31T23:59:59-06:00
count: 10
```
Rules:
- `timezone` should use an IANA timezone like `America/Chicago`
- `startsAt` anchors the first occurrence
- `frequency` and `interval` are the only required recurrence fields
- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules
- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last”
- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable
- `until` and `count` are optional recurrence end bounds
- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above
## 11. SKILL.md Compatibility
A skill package must remain a valid Agent Skills package.
@@ -259,7 +341,7 @@ metadata:
---
```
## 10. Source References
## 12. Source References
A package may point to upstream content instead of vendoring it.
@@ -301,7 +383,7 @@ sources:
- branch-only refs may be allowed in development mode but must warn
- exporters should default to `referenced` for third-party content unless redistribution is clearly allowed
## 11. Resolution Rules
## 13. Resolution Rules
Given a package root, an importer resolves in this order:
@@ -326,13 +408,15 @@ An importer must surface:
- referenced upstream content that requires network fetch
- executable content in skills or scripts
## 12. Import Graph
## 14. Import Graph
A package importer should build a graph from:
- `COMPANY.md`
- `TEAM.md`
- `AGENTS.md`
- `PROJECT.md`
- `TASK.md`
- `SKILL.md`
- local and external refs
@@ -342,9 +426,71 @@ Suggested import UI behavior:
- checkbox at entity level, not raw file level
- selecting an agent auto-selects required docs and referenced skills
- selecting a team auto-selects its subtree
- selecting a project auto-selects its included tasks
- selecting a recurring task should surface its schedule before import
- selecting referenced third-party content shows attribution, license, and fetch policy
## 13. Export Rules
## 15. Vendor Extensions
Vendor-specific data should live outside the base package shape.
For Paperclip, the preferred fidelity extension is:
```text
.paperclip.yaml
```
Example uses:
- adapter type and adapter config
- adapter env inputs and defaults
- runtime settings
- permissions
- budgets
- approval policies
- project execution workspace policies
- issue/task Paperclip-only metadata
Rules:
- the base package must remain readable without the extension
- tools that do not understand a vendor extension should ignore it
- Paperclip tools may emit the vendor extension by default as a sidecar while keeping the base markdown clean
Suggested Paperclip shape:
```yaml
schema: paperclip/v1
agents:
claudecoder:
adapter:
type: claude_local
config:
model: claude-opus-4-6
inputs:
env:
ANTHROPIC_API_KEY:
kind: secret
requirement: optional
default: ""
GH_TOKEN:
kind: secret
requirement: optional
CLAUDE_BIN:
kind: plain
requirement: optional
default: claude
```
Additional rules for Paperclip exporters:
- do not duplicate `promptTemplate` when `AGENTS.md` already contains the agent instructions
- do not export provider-specific secret bindings such as `secretId`, `version`, or `type: secret_ref`
- export env inputs as portable declarations with `required` or `optional` semantics and optional defaults
- warn on system-dependent values such as absolute commands and absolute `PATH` overrides
- omit empty and default-valued Paperclip fields when possible
## 16. Export Rules
A compliant exporter should:
@@ -352,11 +498,15 @@ A compliant exporter should:
- omit machine-local ids and timestamps
- omit secret values
- omit machine-specific paths
- preserve task descriptions and recurrence definitions when exporting tasks
- omit empty/default fields
- default to the vendor-neutral base package
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
- preserve attribution and source references
- prefer `referenced` over silent vendoring for third-party content
- preserve `SKILL.md` as-is when exporting compatible skills
## 14. Licensing And Attribution
## 17. Licensing And Attribution
A compliant tool must:
@@ -366,7 +516,7 @@ A compliant tool must:
- surface missing license metadata as a warning
- surface restrictive or unknown licenses before install/import if content is vendored or mirrored
## 15. Optional Lock File
## 18. Optional Lock File
Authoring does not require a lock file.
@@ -388,23 +538,30 @@ Rules:
- lock files are generated artifacts, not canonical authoring input
- the markdown package remains the source of truth
## 16. Paperclip Mapping
## 19. Paperclip Mapping
Paperclip can map this spec to its runtime model like this:
- base package:
- `COMPANY.md` -> company metadata
- `TEAM.md` -> importable org subtree
- `AGENTS.md` -> agent records plus adapter/runtime config
- `SKILL.md` -> imported skill package, ideally as a managed reusable skill reference
- `AGENTS.md` -> agent identity and instructions
- `PROJECT.md` -> starter project definition
- `TASK.md` -> starter issue/task definition, or automation template when recurrence is present
- `SKILL.md` -> imported skill package
- `sources[]` -> provenance and pinned upstream refs
- Paperclip extension:
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity
Paperclip-specific data should live under:
Inline Paperclip-only metadata that must live inside a shared markdown file should use:
- `metadata.paperclip`
That keeps the base format broader than Paperclip.
## 17. Cutover
This specification itself remains vendor-neutral and intended for any agent-company runtime, not only Paperclip.
## 20. Cutover
Paperclip should cut over to this markdown-first package model as the primary portability format.
@@ -412,7 +569,7 @@ Paperclip should cut over to this markdown-first package model as the primary po
For Paperclip, this should be treated as a hard cutover in product direction rather than a long-lived dual-format strategy.
## 18. Minimal Example
## 21. Minimal Example
```text
lean-dev-shop/
@@ -420,10 +577,24 @@ lean-dev-shop/
├── agents/
│ ├── ceo/AGENTS.md
│ └── cto/AGENTS.md
├── projects/
│ └── q2-launch/
│ ├── PROJECT.md
│ └── tasks/
│ └── monday-review/
│ └── TASK.md
├── teams/
│ └── engineering/TEAM.md
├── tasks/
│ └── weekly-review/TASK.md
└── skills/
└── review/SKILL.md
Optional:
```text
.paperclip.yaml
```
```
**Recommendation**

View File

@@ -115,9 +115,11 @@ export type {
JoinRequest,
InstanceUserRoleGrant,
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,
CompanyPortabilityEnvInput,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilityProjectManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
CompanyPortabilitySource,
@@ -126,6 +128,8 @@ export type {
CompanyPortabilityCollisionStrategy,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewProjectPlan,
CompanyPortabilityPreviewIssuePlan,
CompanyPortabilityPreviewResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
@@ -235,7 +239,7 @@ export {
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
portabilityIncludeSchema,
portabilitySecretRequirementSchema,
portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema,
portabilityAgentManifestEntrySchema,
portabilityManifestSchema,

View File

@@ -1,13 +1,18 @@
export interface CompanyPortabilityInclude {
company: boolean;
agents: boolean;
projects: boolean;
issues: boolean;
}
export interface CompanyPortabilitySecretRequirement {
export interface CompanyPortabilityEnvInput {
key: string;
description: string | null;
agentSlug: string | null;
providerHint: string | null;
kind: "secret" | "plain";
requirement: "required" | "optional";
defaultValue: string | null;
portability: "portable" | "system_dependent";
}
export interface CompanyPortabilityCompanyManifestEntry {
@@ -18,6 +23,38 @@ export interface CompanyPortabilityCompanyManifestEntry {
requireBoardApprovalForNewAgents: boolean;
}
export interface CompanyPortabilityProjectManifestEntry {
slug: string;
name: string;
path: string;
description: string | null;
ownerAgentSlug: string | null;
leadAgentSlug: string | null;
targetDate: string | null;
color: string | null;
status: string | null;
executionWorkspacePolicy: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}
export interface CompanyPortabilityIssueManifestEntry {
slug: string;
identifier: string | null;
title: string;
path: string;
projectSlug: string | null;
assigneeAgentSlug: string | null;
description: string | null;
recurrence: Record<string, unknown> | null;
status: string | null;
priority: string | null;
labelIds: string[];
billingCode: string | null;
executionWorkspaceSettings: Record<string, unknown> | null;
assigneeAdapterOverrides: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}
export interface CompanyPortabilityAgentManifestEntry {
slug: string;
name: string;
@@ -45,7 +82,9 @@ export interface CompanyPortabilityManifest {
includes: CompanyPortabilityInclude;
company: CompanyPortabilityCompanyManifestEntry | null;
agents: CompanyPortabilityAgentManifestEntry[];
requiredSecrets: CompanyPortabilitySecretRequirement[];
projects: CompanyPortabilityProjectManifestEntry[];
issues: CompanyPortabilityIssueManifestEntry[];
envInputs: CompanyPortabilityEnvInput[];
}
export interface CompanyPortabilityExportResult {
@@ -53,6 +92,7 @@ export interface CompanyPortabilityExportResult {
manifest: CompanyPortabilityManifest;
files: Record<string, string>;
warnings: string[];
paperclipExtensionPath: string;
}
export type CompanyPortabilitySource =
@@ -100,6 +140,21 @@ export interface CompanyPortabilityPreviewAgentPlan {
reason: string | null;
}
export interface CompanyPortabilityPreviewProjectPlan {
slug: string;
action: "create" | "update" | "skip";
plannedName: string;
existingProjectId: string | null;
reason: string | null;
}
export interface CompanyPortabilityPreviewIssuePlan {
slug: string;
action: "create" | "skip";
plannedTitle: string;
reason: string | null;
}
export interface CompanyPortabilityPreviewResult {
include: CompanyPortabilityInclude;
targetCompanyId: string | null;
@@ -109,8 +164,10 @@ export interface CompanyPortabilityPreviewResult {
plan: {
companyAction: "none" | "create" | "update";
agentPlans: CompanyPortabilityPreviewAgentPlan[];
projectPlans: CompanyPortabilityPreviewProjectPlan[];
issuePlans: CompanyPortabilityPreviewIssuePlan[];
};
requiredSecrets: CompanyPortabilitySecretRequirement[];
envInputs: CompanyPortabilityEnvInput[];
warnings: string[];
errors: string[];
}
@@ -130,10 +187,13 @@ export interface CompanyPortabilityImportResult {
name: string;
reason: string | null;
}[];
requiredSecrets: CompanyPortabilitySecretRequirement[];
envInputs: CompanyPortabilityEnvInput[];
warnings: string[];
}
export interface CompanyPortabilityExportRequest {
include?: Partial<CompanyPortabilityInclude>;
projects?: string[];
issues?: string[];
projectIssues?: string[];
}

View File

@@ -70,9 +70,11 @@ export type {
} from "./access.js";
export type {
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,
CompanyPortabilityEnvInput,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilityProjectManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
CompanyPortabilitySource,
@@ -81,6 +83,8 @@ export type {
CompanyPortabilityCollisionStrategy,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewProjectPlan,
CompanyPortabilityPreviewIssuePlan,
CompanyPortabilityPreviewResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,

View File

@@ -4,14 +4,19 @@ export const portabilityIncludeSchema = z
.object({
company: z.boolean().optional(),
agents: z.boolean().optional(),
projects: z.boolean().optional(),
issues: z.boolean().optional(),
})
.partial();
export const portabilitySecretRequirementSchema = z.object({
export const portabilityEnvInputSchema = z.object({
key: z.string().min(1),
description: z.string().nullable(),
agentSlug: z.string().min(1).nullable(),
providerHint: z.string().nullable(),
kind: z.enum(["secret", "plain"]),
requirement: z.enum(["required", "optional"]),
defaultValue: z.string().nullable(),
portability: z.enum(["portable", "system_dependent"]),
});
export const portabilityCompanyManifestEntrySchema = z.object({
@@ -39,6 +44,38 @@ export const portabilityAgentManifestEntrySchema = z.object({
metadata: z.record(z.unknown()).nullable(),
});
export const portabilityProjectManifestEntrySchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
path: z.string().min(1),
description: z.string().nullable(),
ownerAgentSlug: z.string().min(1).nullable(),
leadAgentSlug: z.string().min(1).nullable(),
targetDate: z.string().nullable(),
color: z.string().nullable(),
status: z.string().nullable(),
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
metadata: z.record(z.unknown()).nullable(),
});
export const portabilityIssueManifestEntrySchema = z.object({
slug: z.string().min(1),
identifier: z.string().min(1).nullable(),
title: z.string().min(1),
path: z.string().min(1),
projectSlug: z.string().min(1).nullable(),
assigneeAgentSlug: z.string().min(1).nullable(),
description: z.string().nullable(),
recurrence: z.record(z.unknown()).nullable(),
status: z.string().nullable(),
priority: z.string().nullable(),
labelIds: z.array(z.string().min(1)).default([]),
billingCode: z.string().nullable(),
executionWorkspaceSettings: z.record(z.unknown()).nullable(),
assigneeAdapterOverrides: z.record(z.unknown()).nullable(),
metadata: z.record(z.unknown()).nullable(),
});
export const portabilityManifestSchema = z.object({
schemaVersion: z.number().int().positive(),
generatedAt: z.string().datetime(),
@@ -51,10 +88,14 @@ export const portabilityManifestSchema = z.object({
includes: z.object({
company: z.boolean(),
agents: z.boolean(),
projects: z.boolean(),
issues: z.boolean(),
}),
company: portabilityCompanyManifestEntrySchema.nullable(),
agents: z.array(portabilityAgentManifestEntrySchema),
requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]),
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
issues: z.array(portabilityIssueManifestEntrySchema).default([]),
envInputs: z.array(portabilityEnvInputSchema).default([]),
});
export const portabilitySourceSchema = z.discriminatedUnion("type", [
@@ -93,6 +134,9 @@ export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "rep
export const companyPortabilityExportSchema = z.object({
include: portabilityIncludeSchema.optional(),
projects: z.array(z.string().min(1)).optional(),
issues: z.array(z.string().min(1)).optional(),
projectIssues: z.array(z.string().min(1)).optional(),
});
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;

View File

@@ -14,7 +14,7 @@ export {
} from "./adapter-skills.js";
export {
portabilityIncludeSchema,
portabilitySecretRequirementSchema,
portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema,
portabilityAgentManifestEntrySchema,
portabilityManifestSchema,

View File

@@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const companySvc = {
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
const agentSvc = {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
const accessSvc = {
ensureMembership: vi.fn(),
};
const projectSvc = {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
const issueSvc = {
list: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
};
vi.mock("../services/companies.js", () => ({
companyService: () => companySvc,
}));
vi.mock("../services/agents.js", () => ({
agentService: () => agentSvc,
}));
vi.mock("../services/access.js", () => ({
accessService: () => accessSvc,
}));
vi.mock("../services/projects.js", () => ({
projectService: () => projectSvc,
}));
vi.mock("../services/issues.js", () => ({
issueService: () => issueSvc,
}));
const { companyPortabilityService } = await import("../services/company-portability.js");
describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
description: null,
brandColor: "#5c5fff",
requireBoardApprovalForNewAgents: true,
});
agentSvc.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
status: "idle",
role: "engineer",
title: "Software Engineer",
icon: "code",
reportsTo: null,
capabilities: "Writes code",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are ClaudeCoder.",
instructionsFilePath: "/tmp/ignored.md",
cwd: "/tmp/ignored",
command: "/Users/dotta/.local/bin/claude",
model: "claude-opus-4-6",
env: {
ANTHROPIC_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
GH_TOKEN: {
type: "secret_ref",
secretId: "secret-2",
version: "latest",
},
PATH: {
type: "plain",
value: "/usr/bin:/bin",
},
},
},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]);
projectSvc.list.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
issueSvc.getById.mockResolvedValue(null);
issueSvc.getByIdentifier.mockResolvedValue(null);
});
it("exports a clean base package with sanitized Paperclip extension data", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"');
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
const extension = exported.files[".paperclip.yaml"];
expect(extension).toContain('schema: "paperclip/v1"');
expect(extension).not.toContain("promptTemplate");
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"');
expect(extension).toContain('default: ""');
expect(extension).not.toContain("PATH:");
expect(extension).not.toContain("requireBoardApprovalForNewAgents: true");
expect(extension).not.toContain("budgetMonthlyCents: 0");
expect(exported.warnings).toContain("Agent claudecoder command /Users/dotta/.local/bin/claude was omitted from export because it is system-dependent.");
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.envInputs).toEqual([
{
key: "ANTHROPIC_API_KEY",
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
agentSlug: "claudecoder",
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
},
{
key: "GH_TOKEN",
description: "Provide GH_TOKEN for agent claudecoder",
agentSlug: "claudecoder",
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
},
]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,15 @@ export const companiesApi = {
) => api.patch<Company>(`/companies/${companyId}`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) =>
exportBundle: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
projects?: string[];
issues?: string[];
projectIssues?: string[];
},
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),

View File

@@ -79,7 +79,9 @@ export function CompanySettings() {
const packageInclude = useMemo(
() => ({
company: packageIncludeCompany,
agents: packageIncludeAgents
agents: packageIncludeAgents,
projects: false,
issues: false
}),
[packageIncludeAgents, packageIncludeCompany]
);
@@ -376,7 +378,7 @@ export function CompanySettings() {
pushToast({
tone: "success",
title: "Local package loaded",
body: `${Object.keys(parsed.files).length} markdown file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.`
body: `${Object.keys(parsed.files).length} package file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.`
});
} catch (err) {
setLocalPackage(null);
@@ -666,6 +668,9 @@ export function CompanySettings() {
Include agents
</label>
</div>
<p className="text-xs text-muted-foreground">
Export always includes `.paperclip.yaml` as a Paperclip sidecar while keeping the markdown package readable and shareable.
</p>
{exportMutation.data && (
<div className="rounded-md border border-border bg-muted/20 p-3">
@@ -675,7 +680,8 @@ export function CompanySettings() {
<div className="mt-2 text-sm">
{exportMutation.data.rootPath}.tar with{" "}
{Object.keys(exportMutation.data.files).length} file
{Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}.
{Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. Includes{" "}
<span className="font-mono">{exportMutation.data.paperclipExtensionPath}</span>.
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{Object.keys(exportMutation.data.files).map((filePath) => (
@@ -773,7 +779,7 @@ export function CompanySettings() {
{localPackage && (
<span className="text-xs text-muted-foreground">
{localPackage.rootPath ?? "package"} with{" "}
{Object.keys(localPackage.files).length} markdown file
{Object.keys(localPackage.files).length} package file
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
</span>
)}
@@ -889,7 +895,7 @@ export function CompanySettings() {
{importPreview && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3">
<div className="grid gap-2 md:grid-cols-2">
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Company action
@@ -906,6 +912,22 @@ export function CompanySettings() {
{importPreview.plan.agentPlans.length}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Project actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.projectPlans.length}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Task actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.issuePlans.length}
</div>
</div>
</div>
{importPreview.plan.agentPlans.length > 0 && (
@@ -933,18 +955,72 @@ export function CompanySettings() {
</div>
)}
{importPreview.requiredSecrets.length > 0 && (
{importPreview.plan.projectPlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.projectPlans.map((projectPlan) => (
<div
key={projectPlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{projectPlan.slug} {"->"} {projectPlan.plannedName}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{projectPlan.action}
</span>
</div>
{projectPlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{projectPlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.plan.issuePlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.issuePlans.map((issuePlan) => (
<div
key={issuePlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{issuePlan.slug} {"->"} {issuePlan.plannedTitle}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{issuePlan.action}
</span>
</div>
{issuePlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{issuePlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.envInputs.length > 0 && (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Required secrets
Environment inputs
</div>
{importPreview.requiredSecrets.map((secret) => (
{importPreview.envInputs.map((inputValue) => (
<div
key={`${secret.agentSlug ?? "company"}:${secret.key}`}
key={`${inputValue.agentSlug ?? "company"}:${inputValue.key}`}
className="text-xs text-muted-foreground"
>
{secret.key}
{secret.agentSlug ? ` for ${secret.agentSlug}` : ""}
{inputValue.key}
{inputValue.agentSlug ? ` for ${inputValue.agentSlug}` : ""}
{` · ${inputValue.kind}`}
{` · ${inputValue.requirement}`}
{inputValue.defaultValue !== null ? ` · default ${JSON.stringify(inputValue.defaultValue)}` : ""}
{inputValue.portability === "system_dependent" ? " · system-dependent" : ""}
</div>
))}
</div>
@@ -1039,14 +1115,18 @@ async function readLocalPackageSelection(fileList: FileList): Promise<{
/\\/g,
"/"
) || file.name;
if (!relativePath.endsWith(".md")) continue;
const isMarkdown = relativePath.endsWith(".md");
const isPaperclipYaml =
relativePath.endsWith(".paperclip.yaml") ||
relativePath.endsWith(".paperclip.yml");
if (!isMarkdown && !isPaperclipYaml) continue;
const topLevel = relativePath.split("/")[0] ?? null;
if (!rootPath && topLevel) rootPath = topLevel;
files[relativePath] = await file.text();
}
if (Object.keys(files).length === 0) {
throw new Error("No markdown files were found in the selected folder.");
throw new Error("No package files were found in the selected folder.");
}
return { rootPath, files };