Refine company package export format
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
- `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
|
||||
- `sources[]` -> provenance and pinned upstream refs
|
||||
- base package:
|
||||
- `COMPANY.md` -> company metadata
|
||||
- `TEAM.md` -> importable org subtree
|
||||
- `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**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
} from "./adapter-skills.js";
|
||||
export {
|
||||
portabilityIncludeSchema,
|
||||
portabilitySecretRequirementSchema,
|
||||
portabilityEnvInputSchema,
|
||||
portabilityCompanyManifestEntrySchema,
|
||||
portabilityAgentManifestEntrySchema,
|
||||
portabilityManifestSchema,
|
||||
|
||||
204
server/src/__tests__/company-portability.test.ts
Normal file
204
server/src/__tests__/company-portability.test.ts
Normal 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
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user