Compare commits

..

15 Commits

Author SHA1 Message Date
scotttong
aea1b9208a experiment: fix stale asset fallback, wizard cleanup, sidebar badge props
- Return 404 for missing /assets/ paths instead of serving index.html
- Use direct onboarding state instead of effectiveOnboarding wrappers
- Rename badge→textBadge/textBadgeTone props in Sidebar
- Minor JSX cleanup in Inbox

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:39:16 -07:00
scotttong
ea09bca9ad experiment: board chat split-pane fraction, markdown wrap, tooltips
ResizeObserver-driven chat/agent pane split; wrapped markdown bubbles with
horizontal scroll for pre/table; new thread/history affordances.

Made-with: Cursor
2026-04-03 20:20:19 -07:00
scotttong
1304184bdd experiment: unify board chat — Board Room, legacy redirect, split pane
- Remove legacy Chat page and CEOChatPanel; board concierge lives at /board-chat only
- Redirect /chat to /board-chat (preserve search and hash)
- Sidebar: single 'Board Room' nav item; drop duplicate Chat/Board Chat entries
- Breadcrumbs: label board-chat as 'Board Room' when a single crumb
- BoardChat: resizable chat + Agent Feed column, feed filter menu, starter
  prompts, bubble/input/status polish
- Onboarding: post-wizard launch targets board-chat where applicable
- Layout/index.css and dev-fresh-chat.sh: small spacing/script alignment

Made-with: Cursor
2026-04-03 20:20:19 -07:00
scotttong
c47f04b07f experiment: board chat UX polish and beginner guide
- Fix company isolation: reset chat state when switching companies,
  clear stale comment cache, fix Board Operations issue creation
  (status: todo instead of in_progress to avoid assignee requirement)
- Optimistic user messages: show user's message instantly before server
  round-trip for a natural chat feel
- Live status indicators: forward tool-use events from Claude CLI as
  SSE status messages (Running a command, Reading a file, Searching, etc.)
  shown as a separate bar below the streaming response
- Paperclip SVG thinking animation (slowed to 1s loop)
- Chat bubble styling: blue user bubbles, shaped corners via raw CSS
  to bypass --radius:0 design system
- Agent system prompt template: add Model field (default: sonnet)
- Add beginner guide: doc/BOARD-CHAT-GUIDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:37 -07:00
scotttong
fbfc895294 experiment: board concierge skill + web UI chat surface
Add a board-member skill that teaches Claude how to manage a Paperclip
company via chat — covering onboarding, hiring plans, approvals, task
monitoring, cost oversight, and agent system prompt management.

Phase 1 (Claude Code surface):
- Board skill at skills/paperclip-board/SKILL.md with full API reference
- CLI bootstrap command `paperclipai board setup` that installs the skill
  and prints env exports

Phase 2 (Web UI surface):
- New /board/chat/stream endpoint that spawns Claude with the board skill
  as system prompt, passing PAPERCLIP_API_URL and PAPERCLIP_COMPANY_ID
- BoardChat page with streaming responses, status indicators, and
  conversation persistence via Board Operations issue
- Sidebar nav link and route registration

The skill is a portable knowledge layer — same document powers Claude Code
(Surface 1), web UI chat (Surface 2), and future MCP server (Surface 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:37 -07:00
scotttong
b5717e547e experiment: two-layer observer, canned openers, structured action signals
Layer 1 (instant): detectUserIntent() on user message creates work product
+ fires background artifact generation immediately.
Layer 2 (CEO confirm): parseStructuredActions() on %%ACTIONS%% signal in
CEO response, with regex fallback. Deduplicates against Layer 1.

Also adds: canned openers for common messages (hybrid with real streaming),
message action metadata bars, cleanAgentMessage strips action signals,
generate-artifact now posts CEO comment and updates task to in_review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:14 -07:00
scotttong
4ed31a006d experiment: direct CLI streaming, observer pattern, artifact generation
Replace adapter-based chat relay with lightweight claude CLI streaming
endpoint. CEO responses now arrive in 2-5s instead of minutes.

Key changes:
- POST /agents/:id/chat/stream — spawns claude -p directly, SSE streaming
- POST /agents/:id/chat/canned — persist welcome/approval messages
- POST /agents/:id/chat/generate-artifact — background doc generation
- Server-side detectArtifactCommitments() replaces unreliable AI observer
- Frontend: optimistic user messages, typewriter streaming, observer events
- Onboarding: separated name/mission substeps, back navigation
- Dev script: full server restart + fresh company + mission setup
- Removed: canned responses, heartbeat polling, inline plan detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:14 -07:00
scotttong
43d1f393e1 experiment: filter system output from chat, approval UX, wizard sub-steps
- Strip JSON/system init output from agent messages in chat (no more
  raw tool dumps or session IDs visible to users)
- Filter streaming relay chunks that look like system output
- Create "in progress" artifact when user asks for a hiring plan
- Swap approval button order: Reject (left), Approve (right, green)
- Fix chat/artifact pane height to fill viewport without clipping
- Progressive disclosure in wizard step 1: name first, then mission
- Hide agent comments that are entirely system output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:14 -07:00
scotttong
db6895d0a2 experiment: add chat relay endpoint for real-time streaming responses
New POST /api/agents/:id/chat/relay endpoint that calls the adapter
directly and streams stdout back via SSE, bypassing the heartbeat
queue. Comments are persisted normally so conversations stay durable.
Frontend tries the relay first, falls back to poll-based flow if
unavailable.

Backend: 1 new file (agent-chat.ts), 1 line in app.ts.
Frontend: streaming fetch in CEOChatPanel with fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:14 -07:00
scotttong
265832898b experiment: 3-panel CEO chat, artifacts, front door, and UX overhaul
New core product layout: resizable chat + artifacts panel replaces the
old wizard-only flow. Front door (create/grow), onboarding exits to chat,
CEO discusses strategy before planning. Approval actions live in the
artifacts pane, not inline in chat. Chat history drawer, animated
paperclip thinking indicator, optimistic typing, faster polling.

Rename Issue → Task across all frontend UI labels (16 files).
Add global pause/resume all agents on dashboard with sidebar badge.
Move toasts to bottom-right. Add Artifacts page and sidebar nav item.
Reorder wizard: Mission → CEO config → Launch (exits to chat).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:14 -07:00
scotttong
950434a449 experiment: CEO welcome flow, merged steps, orientation screen, UX polish
CEO welcome & user agency:
- Removed auto-posted user message — CEO greets the board first
- "CEO is waking up..." → "CEO is composing..." → welcome message fades in
- Response chips ("Yes, get started!" / "Let's discuss first") fade in after
- Planning task created unassigned — CEO only wakes when user initiates
- "Yes" chip sends directly; "Discuss" pre-fills input for editing

Merged steps 5+6:
- "Approve & hire" on the Plan step creates hire tasks directly
- No redundant confirmation step
- Step 6 is now a welcome/orientation screen (hidden from nav tabs)

Orientation screen:
- Shows what to expect: Tasks, Agents, Approvals, Dashboard
- "Go to dashboard" button closes wizard and navigates

Nav tabs cleaned up:
- Mission → Launch → CEO → Plan → Review (5 visible tabs)
- Orientation step exists but not in nav (no redundant rockets)

Chat UX polish:
- Single-line input (like a URL bar) instead of multi-line textarea
- CEO welcome message always visible in conversation history
- Structured task description tells CEO exact format for role specs
- "Confirm mission" button hidden until user has chosen a path
- Switching paths preserves previously entered text

Parser improvements:
- Handles N. **Role Name** with indented bullets (fallback format)
- Summary field populated from first expertise line when no explicit summary
- Better skip patterns for non-role sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:17:06 -07:00
scotttong
e58bf36647 experiment: chat UX fixes, structured role cards, plan parser improvements
Chat fixes:
- Comment order: sort chronologically (oldest first)
- Reopen+interrupt: user messages reassign task to CEO so it always wakes up
- Strip markdown links from CEO messages to keep user focused on wizard
- Cycling status messages (rotate every 5s) with elapsed timer
- "Review plan" CTA properly disappears when user sends follow-up
- Fetch plan document (not comment summary) for richer role data

Structured role cards:
- 7 fields: Summary, Expertise, Priorities, Boundaries, Tools, Communication, Collaboration
- Collapsible card view with "Show more" / "Show less"
- Full edit mode with labeled textareas per field
- Hire tasks include structured role spec in description

Plan parser:
- Handles "## Role N: Name" format with ### sub-sections
- Handles "### N. Name" format with **Label:** bullets
- Maps CEO's labels (Why→Summary, Responsibilities→Expertise, etc.)
- Skips non-role sections (Summary, Next Steps, Mission, etc.)

Other:
- localStorage persistence for wizard state (survives page refresh)
- Cleaned up step 6 summary (removed redundant company/CEO entries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:17:06 -07:00
scotttong
6d4256b6a4 experiment: add hiring plan review (step 5) and hire tasks (step 6)
Step 5 - Review Hiring Plan:
- Parses CEO's markdown plan into structured role cards
- Each role has a checkbox (include/exclude), edit button, delete button
- Inline editing for role name and description
- "Add role" button to create new roles manually
- "Revise with CEO" button returns to chat (step 4)
- Collapsible raw plan view for reference
- "Approve" button only enabled when at least one role is selected

Step 6 - Make Your First Hires:
- Summary showing company, CEO, and all approved roles
- "Make your first hires" creates one task per approved role
  assigned to the CEO (e.g., "Hire: Content Strategist")
- Navigates to the task list after completion

Plan parsing handles "- **Role Name**: description" markdown format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:16:24 -07:00
scotttong
012cff06cc experiment: add OnboardingChat component and wire into step 4
Creates a new OnboardingChat component that provides an embedded chat
experience between the user and the CEO agent during onboarding.

How it works:
- After CEO creation (step 3), a planning task is auto-created and
  assigned to the CEO with the company mission as context
- An initial comment kicks off the conversation asking the CEO to
  propose a hiring plan
- OnboardingChat polls issuesApi.listComments every 4 seconds
- Messages render as chat bubbles (user on right, agent on left)
- A "thinking" indicator shows when waiting for the agent
- Automatically detects hiring plan patterns in agent responses
  (markdown headers/lists with role names)
- Calls onPlanDetected callback when a plan is found

The existing issue comment system is the backbone — no new server
endpoints needed. The agent wakes up automatically when comments
are posted via the existing wakeup-on-comment pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:15:55 -07:00
scotttong
b5a1880fd3 experiment: redesign onboarding wizard with 6-step mission-driven flow
Replaces the old 4-step wizard (Company → Agent → Task → Launch) with a new
6-step flow that puts the user in control of key moments:

1. Define your mission (required, with questionnaire or direct input)
2. Launch your company! (celebration moment)
3. Bring your CEO to life (agent creation, reframed)
4. Chat with your CEO (placeholder for hiring plan chat)
5. Review hiring plan (placeholder for editable role cards)
6. Make your first hires (summary + task creation)

Key changes:
- Mission/goal is now mandatory (was optional)
- Two paths to define mission: "I know my mission" or "Help me figure it out"
- Prompt chips for inspiration
- Questionnaire generates a draft mission from 4 questions
- Company launch is a celebrated moment before CEO creation
- Step type expanded from 1-4 to 1-6 in DialogContext
- Agent creation step reframed as "giving the CEO a heartbeat"
- Steps 4-5 are placeholders for the chat and plan review components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:15:33 -07:00
946 changed files with 14484 additions and 276001 deletions

View File

@@ -154,14 +154,6 @@ Each AGENTS.md body should include not just what the agent does, but how they fi
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:

View File

@@ -105,13 +105,6 @@ 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

View File

@@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`:
### 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.
- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."`
### Error Handling
- Differentiate timeout vs process error vs parse failure

View File

@@ -1,230 +0,0 @@
---
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

View File

@@ -1,209 +0,0 @@
---
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.

1
.claude/skills/shadcn Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/shadcn

View File

@@ -1,7 +1,3 @@
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/...

View File

@@ -38,8 +38,6 @@
-
> 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
<!--
@@ -59,7 +57,6 @@
- [ ] 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 run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots

View File

@@ -54,11 +54,10 @@ jobs:
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"
echo "pr_created=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -71,26 +70,28 @@ jobs:
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 \
# Create PR if one doesn't already exist
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
if [ -z "$existing" ]; then
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"
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
echo "Created new PR."
else
echo "PR already exists: $pr_url"
echo "PR #$existing already exists, branch updated via force push."
fi
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr_created=true" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge for lockfile PR
if: steps.upsert-pr.outputs.pr_url != ''
if: steps.upsert-pr.outputs.pr_created == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
pr_url="$(gh pr list --head chore/refresh-lockfile --json url --jq '.[0].url')"
if [ -z "$pr_url" ]; then
echo "Error: lockfile PR was not found." >&2
exit 1
fi
gh pr merge --auto --squash --delete-branch "$pr_url"

4
.gitignore vendored
View File

@@ -1,7 +1,4 @@
node_modules
node_modules/
**/node_modules
**/node_modules/
dist/
.env
*.tsbuildinfo
@@ -35,7 +32,6 @@ server/src/**/*.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
diagnostics/
# Editor / tool temp files
*.tmp

View File

@@ -1,3 +1 @@
Dotta <bippadotta@protonmail.com> <34892728+cryppadotta@users.noreply.github.com>
Dotta <bippadotta@protonmail.com> <forgottenrunes@protonmail.com>
Dotta <bippadotta@protonmail.com> <dotta@example.com>
Dotta <bippadotta@protonmail.com> Forgotten <forgottenrunes@protonmail.com>

View File

@@ -81,8 +81,8 @@ If you change schema/API behavior, update all impacted layers:
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.
5. Keep plan docs dated and centralized.
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
## 6. Database Change Workflow
@@ -108,21 +108,6 @@ Notes:
## 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.
Run this full check before claiming done:
```sh
@@ -153,18 +138,7 @@ When adding endpoints:
- 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
## 10. Definition of Done
A change is done when all are true:
@@ -172,45 +146,3 @@ A change is done when all are true:
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 an **external-only** 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` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path).
- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch.
### Hermes (plugin only)
- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded.
- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source.
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo.
### 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 — remove built-in when fully externalizing
- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm)

View File

@@ -37,11 +37,7 @@ PRs that follow this path are **much** more likely to be accepted, even when the
### 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.
### 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.
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, and a Checklist.
### Tests Must Pass
@@ -51,21 +47,6 @@ All tests must pass before a PR can be merged. Run them locally first and verify
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
## 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

View File

@@ -2,7 +2,15 @@ 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 \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
&& mkdir -p -m 755 /etc/apt/keyrings \
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
@@ -20,7 +28,6 @@ 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/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
@@ -48,9 +55,6 @@ 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 \
&& 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

View File

@@ -177,14 +177,6 @@ Open source. Self-hosted. No Paperclip account required.
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:
@@ -233,15 +225,11 @@ 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 test:run # Run tests
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/>
@@ -255,23 +243,14 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ✅ Skills Manager
- ✅ Scheduled Routines
- ✅ Better Budgeting
- Agent Reviews and Approvals
- ✅ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Artifacts & Work Products
- ⚪ Memory / Knowledge
- ⚪ Enforced Outcomes
- ⚪ MAXIMIZER MODE
- ⚪ Deep Planning
- ⚪ Work Queues
- ⚪ Self-Organization
- ⚪ Automatic Organizational Learning
- Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- ⚪ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](ROADMAP.md).
<br/>
## Community & Plugins
@@ -284,12 +263,12 @@ Paperclip collects anonymous usage telemetry to help us understand how the produ
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 |
| 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

View File

@@ -1,97 +0,0 @@
# 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 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.

View File

@@ -1,8 +0,0 @@
# 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.

36
UX-EXPERIMENTS.md Normal file
View File

@@ -0,0 +1,36 @@
# Onboarding UX Experiments
Tracking file for UX prototyping on the `sockmonster-UX-experimentation` branch.
## Ideas & Feedback
<!-- Add your onboarding feedback and ideas here. We'll update status as we go. -->
| # | Idea | Status | Commit(s) | Notes |
|---|------|--------|-----------|-------|
| 1 | Mission mandatory + two paths | Done | a35fac7 | Questionnaire + direct input, prompt chips |
| 2 | Launch celebration (step 2) | Done | a35fac7 | "Company is live!" moment after mission |
| 3 | CEO creation reframed | Done | a35fac7 | "Bring your CEO to life" / "give it a heartbeat" |
| 4 | Chat with CEO | Done | b4ef061 | OnboardingChat polls comments, detects plans |
| 5 | Hiring plan review | Done | b60fcd8 | Editable role cards, add/edit/remove, revise with CEO |
| 6 | Make your first hires | Done | b60fcd8 | Creates hire tasks per approved role |
| 7 | Chat comment order fix | Done | pending | Sort chronologically (oldest first) |
| 8 | Chat reopen/interrupt fix | Done | pending | User comments reopen + interrupt so CEO wakes up |
| 9 | Rich heartbeat status in chat | Done | — | Cycling status messages with elapsed timer |
| 10 | Merge steps 5+6, add guided tour | Todo | — | Remove redundant confirm step, add post-wizard orientation |
| 11 | Re-invokable guided tour | Todo | — | User can re-trigger tour from settings or help menu |
| 12 | Persistent CEO chat in dashboard | Future | — | Long-term: CEO chat as command center, drives toward tasks/goals/projects |
## Experiment Log
### 2026-03-17: Initial 6-step wizard prototype
Rewrote the onboarding wizard from 4 steps to 6 steps:
1. Define mission (required) — two paths: direct or questionnaire
2. Launch celebration — "Your company is live!"
3. Create CEO — reframed as "bring to life" / heartbeat
4. Chat with CEO — placeholder (needs OnboardingChat)
5. Review hiring plan — placeholder (needs editable cards)
6. Make first hires — placeholder (needs task creation logic)
Steps 1-3 are functional. Steps 4-6 have placeholder UI.

View File

@@ -1,143 +0,0 @@
- 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

13
artifacts/decision-log.md Normal file
View File

@@ -0,0 +1,13 @@
# Decision Log — skilltest
## 2026-03-20
- Created company **skilltest** — mission: "We add smart chatbots to your business"
- Budget: $500/month
- Hired **Skillbot CEO** (brain icon, claude_local/sonnet, $100/mo)
- CEO working directory: /Users/squadbot/Projects/test
- Created hiring plan with 4 roles for first project: embed Claude Code chatbot into Paperclip web UI
- Hired **Platform Engineer** (cpu icon, $100/mo) — model-agnostic adapter layer
- Hired **Frontend Developer** (code icon, $100/mo) — embedded chat UI
- Hired **Prompt Engineer** (wand icon, $75/mo) — system prompts and conversation design
- Hired **Conversation Tester** (microscope icon, $75/mo) — QA and adapter parity testing
- All hires approved by board. Total team budget: $450/mo

34
artifacts/hiring-plan.md Normal file
View File

@@ -0,0 +1,34 @@
# Hiring Plan — skilltest
## Mission
We add smart chatbots to your business. Focus: internal knowledge bots with model-agnostic adapters.
## First Project
Embed a Claude Code chatbot with the Paperclip skill into the Paperclip web UI.
## Roles
### 1. Platform Engineer
- **Focus:** Model-agnostic adapter layer, API design, LLM integration
- **Reports to:** Skillbot CEO
- **Budget:** $100/mo
### 2. Frontend Developer
- **Focus:** Embedded chatbot widget UI, streaming UX, Paperclip web integration
- **Reports to:** Skillbot CEO
- **Budget:** $100/mo
### 3. Prompt Engineer
- **Focus:** System prompts, conversation flows, knowledge retrieval patterns, cross-model portability
- **Reports to:** Skillbot CEO
- **Budget:** $75/mo
### 4. Conversation Tester
- **Focus:** QA, conversation quality, adapter parity testing, end-to-end UI testing
- **Reports to:** Skillbot CEO
- **Budget:** $75/mo
## Total Budget
- Team: $350/mo
- CEO: $100/mo
- **Total: $450/mo** (of $500 budget)

View File

@@ -12,7 +12,7 @@
<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>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<br/>
@@ -177,14 +177,6 @@ Open source. Self-hosted. No Paperclip account required.
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:
@@ -233,15 +225,11 @@ 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 test:run # Run tests
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/>
@@ -258,7 +246,7 @@ See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc
- ⚪ Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- Multiple Human Users
- Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App

View File

@@ -287,11 +287,6 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
});
await api(apiBase, `/api/companies/${sourceCompany.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requireBoardApprovalForNewAgents: false }),
});
const sourceAgent = await api<{ id: string; name: string }>(
apiBase,

View File

@@ -220,7 +220,6 @@ describe("renderCompanyImportPreview", () => {
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],
@@ -251,7 +250,6 @@ describe("renderCompanyImportPreview", () => {
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
@@ -267,7 +265,6 @@ describe("renderCompanyImportPreview", () => {
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
@@ -435,7 +432,6 @@ describe("import selection catalog", () => {
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
import { buildPresetServerConfig } from "../config/server-bind.js";
describe("network bind helpers", () => {
it("rejects non-loopback bind modes in local_trusted", () => {
expect(
validateConfiguredBindMode({
deploymentMode: "local_trusted",
deploymentExposure: "private",
bind: "lan",
host: "0.0.0.0",
}),
).toContain("local_trusted requires server.bind=loopback");
});
it("resolves tailnet bind using the detected tailscale address", () => {
const resolved = resolveRuntimeBind({
bind: "tailnet",
host: "127.0.0.1",
tailnetBindHost: "100.64.0.8",
});
expect(resolved.errors).toEqual([]);
expect(resolved.host).toBe("100.64.0.8");
});
it("requires a custom bind host when bind=custom", () => {
const resolved = resolveRuntimeBind({
bind: "custom",
host: "127.0.0.1",
});
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
});
it("stores the detected tailscale address for tailnet presets", () => {
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
expect(preset.server.host).toBe("100.64.0.8");
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
});
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
expect(preset.server.host).toBe("127.0.0.1");
});
});

View File

@@ -74,11 +74,6 @@ function createExistingConfigFixture() {
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
}
function createFreshConfigPath() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
return path.join(root, ".paperclip", "config.json");
}
describe("onboard", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
@@ -110,57 +105,4 @@ describe("onboard", () => {
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
const configPath = createFreshConfigPath();
process.env.HOST = "0.0.0.0";
process.env.PAPERCLIP_BIND = "lan";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
it("supports authenticated/private quickstart bind presets", async () => {
const configPath = createFreshConfigPath();
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("tailnet");
expect(raw.server.host).toBe("100.64.0.8");
});
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
const configPath = createFreshConfigPath();
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("tailnet");
expect(raw.server.host).toBe("127.0.0.1");
});
it("ignores deployment env overrides during --yes quickstart", async () => {
const configPath = createFreshConfigPath();
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
});

View File

@@ -2,36 +2,17 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
agents,
authUsers,
companies,
createDb,
issueComments,
issues,
projects,
routines,
routineTriggers,
} from "@paperclipai/db";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
pauseSeededScheduledRoutines,
quarantineSeededWorktreeExecutionState,
readSourceAttachmentBody,
rebindWorkspaceCwd,
resolveSourceConfigPath,
resolveWorktreeReseedSource,
resolveWorktreeReseedTargetPaths,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeRepairCommand,
worktreeInitCommand,
worktreeMakeCommand,
worktreeReseedCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
@@ -44,22 +25,9 @@ import {
sanitizeWorktreeInstanceId,
} from "../commands/worktree-lib.js";
import type { PaperclipConfig } from "../config/schema.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
afterEach(() => {
process.chdir(ORIGINAL_CWD);
@@ -286,138 +254,6 @@ describe("worktree helpers", () => {
expect(full.nullifyColumns).toEqual({});
});
itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => {
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-");
const db = createDb(tempDb.connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const idleAgentId = randomUUID();
const inProgressIssueId = randomUUID();
const todoIssueId = randomUUID();
const reviewIssueId = randomUUID();
const userIssueId = randomUUID();
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "WTQ",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { enabled: true, intervalSec: 60 },
wakeOnDemand: true,
},
permissions: {},
},
{
id: idleAgentId,
companyId,
name: "Reviewer",
role: "reviewer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { enabled: false, intervalSec: 300 } },
permissions: {},
},
]);
await db.insert(issues).values([
{
id: inProgressIssueId,
companyId,
title: "Copied in-flight issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: "WTQ-1",
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-18T00:00:00.000Z"),
},
{
id: todoIssueId,
companyId,
title: "Copied assigned todo issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 2,
identifier: "WTQ-2",
},
{
id: reviewIssueId,
companyId,
title: "Copied assigned review issue",
status: "in_review",
priority: "medium",
assigneeAgentId: idleAgentId,
issueNumber: 3,
identifier: "WTQ-3",
},
{
id: userIssueId,
companyId,
title: "Copied user issue",
status: "todo",
priority: "medium",
assigneeUserId: "user-1",
issueNumber: 4,
identifier: "WTQ-4",
},
]);
await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({
disabledTimerHeartbeats: 1,
resetRunningAgents: 1,
quarantinedInProgressIssues: 1,
unassignedTodoIssues: 1,
unassignedReviewIssues: 1,
});
const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(quarantinedAgent?.status).toBe("idle");
expect(quarantinedAgent?.runtimeConfig).toMatchObject({
heartbeat: { enabled: false, intervalSec: 60 },
wakeOnDemand: true,
});
const [inProgressIssue] = await db.select().from(issues).where(eq(issues.id, inProgressIssueId));
expect(inProgressIssue?.status).toBe("blocked");
expect(inProgressIssue?.assigneeAgentId).toBeNull();
expect(inProgressIssue?.executionAgentNameKey).toBeNull();
expect(inProgressIssue?.executionLockedAt).toBeNull();
const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId));
expect(todoIssue?.status).toBe("todo");
expect(todoIssue?.assigneeAgentId).toBeNull();
const [reviewIssue] = await db.select().from(issues).where(eq(issues.id, reviewIssueId));
expect(reviewIssue?.status).toBe("in_review");
expect(reviewIssue?.assigneeAgentId).toBeNull();
const [userIssue] = await db.select().from(issues).where(eq(issues.id, userIssueId));
expect(userIssue?.status).toBe("todo");
expect(userIssue?.assigneeUserId).toBe("user-1");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("Quarantined during worktree seed");
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
await tempDb.cleanup();
}
}, 20_000);
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
@@ -511,97 +347,6 @@ describe("worktree helpers", () => {
}
});
itEmbeddedPostgres(
"seeds authenticated users into minimally cloned worktree instances",
async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
const sourceHome = path.join(tempRoot, "source-home");
const sourceConfigDir = path.join(sourceHome, "instances", "source");
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
const sourceEnvPath = path.join(sourceConfigDir, ".env");
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const originalCwd = process.cwd();
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
try {
const sourceDbClient = createDb(sourceDb.connectionString);
await sourceDbClient.insert(authUsers).values({
id: "user-existing",
email: "existing@paperclip.ing",
name: "Existing User",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
});
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.mkdirSync(worktreeRoot, { recursive: true });
const sourceConfig = buildSourceConfig();
sourceConfig.database = {
mode: "postgres",
embeddedPostgresDataDir: path.join(sourceConfigDir, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(sourceConfigDir, "backups"),
},
connectionString: sourceDb.connectionString,
};
sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs");
sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage");
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8");
fs.writeFileSync(sourceEnvPath, "", "utf8");
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
process.chdir(worktreeRoot);
await worktreeInitCommand({
name: "PAP-999-auth-seed",
home: worktreeHome,
fromConfig: sourceConfigPath,
force: true,
});
const targetConfig = JSON.parse(
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
) as PaperclipConfig;
const { default: EmbeddedPostgres } = await import("embedded-postgres");
const targetPg = new EmbeddedPostgres({
databaseDir: targetConfig.database.embeddedPostgresDataDir,
user: "paperclip",
password: "paperclip",
port: targetConfig.database.embeddedPostgresPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});
await targetPg.start();
try {
const targetDb = createDb(
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
);
const seededUsers = await targetDb.select().from(authUsers);
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
} finally {
await targetPg.stop();
}
} finally {
process.chdir(originalCwd);
await sourceDb.cleanup();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
},
20000,
);
it("avoids ports already claimed by sibling worktree instance configs", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
const repoRoot = path.join(tempRoot, "repo");
@@ -736,234 +481,6 @@ describe("worktree helpers", () => {
}
});
it("requires an explicit reseed source", () => {
expect(() => resolveWorktreeReseedSource({})).toThrow(
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
);
});
it("rejects mixed reseed source selectors", () => {
expect(() => resolveWorktreeReseedSource({
from: "current",
fromInstance: "default",
})).toThrow(
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
);
});
it("derives worktree reseed target paths from the adjacent env file", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-"));
const worktreeRoot = path.join(tempRoot, "repo");
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
const envPath = path.join(worktreeRoot, ".paperclip", ".env");
try {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
fs.writeFileSync(
envPath,
[
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=pap-1132-chat",
].join("\n"),
"utf8",
);
expect(
resolveWorktreeReseedTargetPaths({
configPath,
rootPath: worktreeRoot,
}),
).toMatchObject({
cwd: worktreeRoot,
homeDir: "/tmp/paperclip-worktrees",
instanceId: "pap-1132-chat",
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects reseed targets without worktree env metadata", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-"));
const worktreeRoot = path.join(tempRoot, "repo");
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
try {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8");
expect(() =>
resolveWorktreeReseedTargetPaths({
configPath,
rootPath: worktreeRoot,
})).toThrow("does not look like a worktree-local Paperclip instance");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceRoot = path.join(tempRoot, "source");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const currentInstanceId = "existing-worktree";
const currentPaths = resolveWorktreeLocalPaths({
cwd: repoRoot,
homeDir,
instanceId: currentInstanceId,
});
const sourcePaths = resolveWorktreeLocalPaths({
cwd: sourceRoot,
homeDir: path.join(tempRoot, ".paperclip-source"),
instanceId: "default",
});
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(sourceRoot, { recursive: true });
const currentConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: currentPaths,
serverPort: 3114,
databasePort: 54341,
});
const sourceConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: sourcePaths,
serverPort: 3200,
databasePort: 54400,
});
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
fs.writeFileSync(
currentPaths.envPath,
[
`PAPERCLIP_HOME=${homeDir}`,
`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`,
"PAPERCLIP_WORKTREE_NAME=existing-name",
"PAPERCLIP_WORKTREE_COLOR=\"#112233\"",
].join("\n"),
"utf8",
);
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
await worktreeReseedCommand({
fromConfig: sourcePaths.configPath,
yes: true,
});
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8");
expect(rewrittenConfig.server.port).toBe(3114);
expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341);
expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir);
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it("restores the current worktree config and instance data if reseed fails", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceRoot = path.join(tempRoot, "source");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const currentInstanceId = "rollback-worktree";
const currentPaths = resolveWorktreeLocalPaths({
cwd: repoRoot,
homeDir,
instanceId: currentInstanceId,
});
const sourcePaths = resolveWorktreeLocalPaths({
cwd: sourceRoot,
homeDir: path.join(tempRoot, ".paperclip-source"),
instanceId: "default",
});
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
fs.mkdirSync(currentPaths.instanceRoot, { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(sourceRoot, { recursive: true });
const currentConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: currentPaths,
serverPort: 3114,
databasePort: 54341,
});
const sourceConfig = {
...buildSourceConfig(),
database: {
mode: "postgres",
connectionString: "",
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: sourcePaths.secretsKeyFilePath,
},
},
} as PaperclipConfig;
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8");
fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8");
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
await expect(worktreeReseedCommand({
fromConfig: sourcePaths.configPath,
yes: true,
})).rejects.toThrow("Source instance uses postgres mode but has no connection string");
const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8");
const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8");
expect(restoredConfig.server.port).toBe(3114);
expect(restoredConfig.database.embeddedPostgresPort).toBe(54341);
expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
expect(restoredMarker).toBe("keep me");
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
@@ -1074,246 +591,4 @@ describe("worktree helpers", () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it("no-ops on the primary checkout unless --branch is provided", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.chdir(repoRoot);
await worktreeRepairCommand({});
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("repairs the current linked worktree when Paperclip metadata is missing", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me");
const sourceConfigPath = path.join(tempRoot, "source-config.json");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const worktreePaths = resolveWorktreeLocalPaths({
cwd: worktreePath,
homeDir: worktreeHome,
instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)),
});
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], {
cwd: repoRoot,
stdio: "ignore",
});
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true });
fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8");
process.chdir(worktreePath);
await worktreeRepairCommand({
fromConfig: sourceConfigPath,
home: worktreeHome,
noSeed: true,
});
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it("creates and repairs a missing branch worktree when --branch is provided", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceConfigPath = path.join(tempRoot, "source-config.json");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const originalCwd = process.cwd();
const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
process.chdir(repoRoot);
await worktreeRepairCommand({
branch: "feature/repair-me",
fromConfig: sourceConfigPath,
home: worktreeHome,
noSeed: true,
});
expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
});
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
it("pauses only routines with enabled schedule triggers", async () => {
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-");
const db = createDb(tempDb.connectionString);
const companyId = randomUUID();
const projectId = randomUUID();
const agentId = randomUUID();
const activeScheduledRoutineId = randomUUID();
const activeApiRoutineId = randomUUID();
const pausedScheduledRoutineId = randomUUID();
const archivedScheduledRoutineId = randomUUID();
const disabledScheduleRoutineId = randomUUID();
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Project",
status: "in_progress",
});
await db.insert(routines).values([
{
id: activeScheduledRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Active scheduled",
status: "active",
},
{
id: activeApiRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Active API",
status: "active",
},
{
id: pausedScheduledRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Paused scheduled",
status: "paused",
},
{
id: archivedScheduledRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Archived scheduled",
status: "archived",
},
{
id: disabledScheduleRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Disabled schedule",
status: "active",
},
]);
await db.insert(routineTriggers).values([
{
companyId,
routineId: activeScheduledRoutineId,
kind: "schedule",
enabled: true,
cronExpression: "0 9 * * *",
timezone: "UTC",
},
{
companyId,
routineId: activeApiRoutineId,
kind: "api",
enabled: true,
},
{
companyId,
routineId: pausedScheduledRoutineId,
kind: "schedule",
enabled: true,
cronExpression: "0 10 * * *",
timezone: "UTC",
},
{
companyId,
routineId: archivedScheduledRoutineId,
kind: "schedule",
enabled: true,
cronExpression: "0 11 * * *",
timezone: "UTC",
},
{
companyId,
routineId: disabledScheduleRoutineId,
kind: "schedule",
enabled: false,
cronExpression: "0 12 * * *",
timezone: "UTC",
},
]);
const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString);
expect(pausedCount).toBe(1);
const rows = await db.select({ id: routines.id, status: routines.status }).from(routines);
const statusById = new Map(rows.map((row) => [row.id, row.status]));
expect(statusById.get(activeScheduledRoutineId)).toBe("paused");
expect(statusById.get(activeApiRoutineId)).toBe("active");
expect(statusById.get(pausedScheduledRoutineId)).toBe("paused");
expect(statusById.get(archivedScheduledRoutineId)).toBe("archived");
expect(statusById.get(disabledScheduleRoutineId)).toBe("active");
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
await tempDb.cleanup();
}
}, 20_000);
});

View File

@@ -1,21 +1,24 @@
import { inferBindModeFromHost } from "@paperclipai/shared";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) {
const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
const mode = config.server.deploymentMode;
const exposure = config.server.exposure;
const auth = config.auth;
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
if (mode === "local_trusted") {
if (bind !== "loopback") {
if (!isLoopbackHost(config.server.host)) {
return {
name: "Deployment/auth mode",
status: "fail",
message: `local_trusted requires loopback binding (found ${bind})`,
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
};
}
return {
@@ -83,6 +86,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
return {
name: "Deployment/auth mode",
status: "pass",
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
};
}

View File

@@ -3,7 +3,6 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { inferBindModeFromHost } from "@paperclipai/shared";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
@@ -41,13 +40,9 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");
}
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
const host =
bind === "custom"
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
: config?.server.host ?? "localhost";
const host = config?.server.host ?? "localhost";
const port = config?.server.port ?? 3100;
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
const publicHost = host === "0.0.0.0" ? "localhost" : host;
return `http://${publicHost}:${port}`;
}

View File

@@ -0,0 +1,208 @@
import { Command } from "commander";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
handleCommandError,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface BoardSetupOptions extends BaseClientOptions {
companyId?: string;
installSkills?: boolean;
}
interface SkillsInstallSummary {
tool: string;
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: string,
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
// Only install the board skill
const boardEntry = entries.find((e) => e.isDirectory() && e.name === "paperclip-board");
if (!boardEntry) {
summary.failed.push({ name: "paperclip-board", error: "Skill directory not found" });
return summary;
}
const source = path.join(sourceSkillsDir, boardEntry.name);
const target = path.join(targetSkillsDir, boardEntry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
if (existing.isSymbolicLink()) {
await fs.unlink(target);
} else {
summary.skipped.push(boardEntry.name);
return summary;
}
}
try {
await fs.symlink(source, target);
summary.linked.push(boardEntry.name);
} catch (err) {
summary.failed.push({
name: boardEntry.name,
error: err instanceof Error ? err.message : String(err),
});
}
return summary;
}
function buildBoardEnvExports(input: { apiBase: string; companyId?: string }): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
const lines = [`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`];
if (input.companyId) {
lines.push(`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`);
}
return lines.join("\n");
}
export function registerBoardCommands(program: Command): void {
const board = program.command("board").description("Board member operations");
addCommonClientOptions(
board
.command("setup")
.description(
"Install the board-member skill for Claude Code and print shell exports for managing your Paperclip company",
)
.option("-C, --company-id <id>", "Company ID (if you already have one)")
.option("--no-install-skills", "Skip installing the board skill into ~/.claude/skills")
.action(async (opts: BoardSetupOptions) => {
try {
const ctx = resolveCommandContext(opts);
// Attempt to auto-detect company if not provided
let companyId = opts.companyId?.trim() || ctx.companyId;
if (!companyId) {
try {
const companies = await ctx.api.get<Array<{ id: string; name: string }>>(
"/api/companies",
);
if (companies && companies.length === 1) {
companyId = companies[0].id;
console.log(`Auto-detected company: ${companies[0].name} (${companyId})`);
} else if (companies && companies.length > 1) {
console.log(
"Multiple companies found. Pass --company-id or set PAPERCLIP_COMPANY_ID:",
);
for (const c of companies) {
console.log(` ${c.id} ${c.name}`);
}
}
} catch {
// Server might not be running yet — that's OK
}
}
// Install skills
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [
path.resolve(process.cwd(), "skills"),
]);
if (!skillsDir) {
console.log(
"Warning: Could not locate skills directory. Skipping skill installation.",
);
} else {
installSummaries.push(
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
}
const exportsText = buildBoardEnvExports({
apiBase: ctx.api.apiBase,
companyId,
});
if (ctx.json) {
const output = {
companyId,
skills: installSummaries,
exports: exportsText,
};
console.log(JSON.stringify(output, null, 2));
return;
}
// Print summary
console.log("");
console.log("Board setup complete!");
console.log("");
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
if (summary.linked.length > 0) {
console.log(
`Skill installed: ${summary.linked.join(", ")}${summary.target}`,
);
}
for (const failed of summary.failed) {
console.log(` Failed: ${failed.name}: ${failed.error}`);
}
}
console.log("");
}
console.log("# Run this in your shell before launching Claude Code:");
console.log(exportsText);
console.log("");
console.log("# Then start Claude Code:");
console.log("claude");
if (!companyId) {
console.log("");
console.log(
"Note: No company detected. Claude Code will guide you through creating one.",
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}

View File

@@ -54,7 +54,6 @@ function defaultConfig(): PaperclipConfig {
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],

View File

@@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
const result = await runDatabaseBackup({
connectionString: connection.value,
backupDir,
retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 },
retentionDays,
filenamePrefix,
});
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);

View File

@@ -3,14 +3,10 @@ import path from "node:path";
import pc from "picocolors";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
inferBindModeFromHost,
resolveRuntimeBind,
type BindMode,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
@@ -27,7 +23,6 @@ import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import { buildPresetServerConfig } from "../config/server-bind.js";
import {
describeLocalInstancePaths,
expandHomePrefix,
@@ -51,14 +46,10 @@ type OnboardOptions = {
run?: boolean;
yes?: boolean;
invokedByRun?: boolean;
bind?: BindMode;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
const TAILNET_BIND_WARNING =
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
const ONBOARD_ENV_KEYS = [
"PAPERCLIP_PUBLIC_URL",
"DATABASE_URL",
@@ -68,9 +59,6 @@ const ONBOARD_ENV_KEYS = [
"PAPERCLIP_DB_BACKUP_DIR",
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"PAPERCLIP_TAILNET_BIND_HOST",
"HOST",
"PORT",
"SERVE_UI",
@@ -116,62 +104,29 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
return path.resolve(expandHomePrefix(rawValue.trim()));
}
function describeServerBinding(server: Pick<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
const bind = server.bind ?? inferBindModeFromHost(server.host);
const detail =
bind === "custom"
? server.customBindHost ?? server.host
: bind === "tailnet"
? "detected tailscale address"
: server.host;
return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`;
}
function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
function quickstartDefaultsFromEnv(): {
defaults: OnboardDefaults;
usedEnvKeys: string[];
ignoredEnvKeys: Array<{ key: string; reason: string }>;
} {
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
const instanceId = resolvePaperclipInstanceId();
const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
const publicUrl = preferTrustedLocal
? undefined
: (
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined
);
const deploymentMode = preferTrustedLocal
? "local_trusted"
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined;
const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
DEPLOYMENT_EXPOSURES,
);
const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const bindFromEnv = parseEnumFromEnv<BindMode>(process.env.PAPERCLIP_BIND, BIND_MODES);
const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined;
const hostFromEnv = process.env.HOST?.trim() || undefined;
const configuredBindHost = customBindHostFromEnv ?? hostFromEnv;
const bind = preferTrustedLocal
? "loopback"
: (
deploymentMode === "local_trusted"
? "loopback"
: (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan"))
);
const resolvedBind = resolveRuntimeBind({
bind,
host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"),
customBindHost: customBindHostFromEnv,
tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(),
});
const authPublicBaseUrl = publicUrl;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
@@ -228,9 +183,7 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
server: {
deploymentMode,
exposure: deploymentExposure,
bind: resolvedBind.bind,
...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}),
host: resolvedBind.host,
host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT) || 3100,
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
@@ -267,49 +220,12 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
},
};
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
if (preferTrustedLocal) {
const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults";
for (const key of [
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"HOST",
"PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"PAPERCLIP_PUBLIC_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
] as const) {
if (process.env[key] !== undefined) {
ignoredEnvKeys.push({ key, reason: forcedLocalReason });
}
}
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
reason: "Ignored because deployment mode local_trusted always forces private exposure",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND_HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) {
ignoredEnvKeys.push({
key: "HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
@@ -323,10 +239,6 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
}
export async function onboard(opts: OnboardOptions): Promise<void> {
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
}
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
const configPath = resolveConfigPath(opts.config);
@@ -381,7 +293,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
@@ -424,13 +336,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(
pc.dim(
opts.bind
? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.`
: "`--yes` enabled: using Quickstart defaults.",
),
);
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
} else {
const setupModeChoice = await p.select({
message: "Choose setup path",
@@ -459,9 +365,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
if (tc) trackInstallStarted(tc);
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
preferTrustedLocal: opts.yes === true && !opts.bind,
});
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
let {
database,
logging,
@@ -471,19 +375,6 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
secrets,
} = derivedDefaults;
if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") {
const preset = buildPresetServerConfig(opts.bind, {
port: server.port,
allowedHostnames: server.allowedHostnames,
serveUi: server.serveUi,
});
server = preset.server;
auth = preset.auth;
if (opts.bind === "tailnet" && server.host === "127.0.0.1") {
p.log.warn(TAILNET_BIND_WARNING);
}
}
if (setupMode === "advanced") {
p.log.step(pc.bold("Database"));
database = await promptDatabase(database);
@@ -571,13 +462,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
} else {
p.log.step(pc.bold("Quickstart"));
p.log.message(
pc.dim(
opts.bind
? `Using quickstart defaults with bind=${opts.bind}.`
: `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`,
),
);
p.log.message(pc.dim("Using quickstart defaults."));
if (usedEnvKeys.length > 0) {
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
} else {
@@ -636,7 +521,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${database.mode}`,
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
`Logging: ${logging.mode} -> ${logging.logDir}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
`Storage: ${storage.provider}`,

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
import pc from "picocolors";
@@ -22,7 +21,6 @@ interface RunOptions {
instance?: string;
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
}
interface StartedServer {
@@ -59,7 +57,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
}
p.log.step("No config found. Starting onboarding...");
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
await onboard({ config: configPath, invokedByRun: true });
}
p.log.step("Running doctor checks...");
@@ -148,35 +146,11 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
}
}
function ensureDevWorkspaceBuildDeps(projectRoot: string): void {
const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs");
if (!fs.existsSync(buildScript)) return;
const result = spawnSync(process.execPath, [buildScript], {
cwd: projectRoot,
stdio: "inherit",
timeout: 120_000,
});
if (result.error) {
throw new Error(
`Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`,
);
}
if ((result.status ?? 1) !== 0) {
throw new Error(
"Failed to prepare workspace build artifacts before starting the Paperclip dev server.",
);
}
}
async function importServerEntry(): Promise<StartedServer> {
// Dev mode: try local workspace path (monorepo with tsx)
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
if (fs.existsSync(devEntry)) {
ensureDevWorkspaceBuildDeps(projectRoot);
maybeEnableUiDevMiddleware(devEntry);
const mod = await import(pathToFileURL(devEntry).href);
return await startServerFromModule(mod, devEntry);

View File

@@ -214,8 +214,6 @@ export function buildWorktreeConfig(input: {
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
...(source?.server.bind ? { bind: source.server.bind } : {}),
...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}),
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],

View File

@@ -39,8 +39,6 @@ import {
issues,
projectWorkspaces,
projects,
routines,
routineTriggers,
runDatabaseBackup,
runDatabaseRestore,
createEmbeddedPostgresLogBuffer,
@@ -82,7 +80,6 @@ import {
type WorktreeInitOptions = {
name?: string;
color?: string;
instance?: string;
home?: string;
fromConfig?: string;
@@ -93,7 +90,6 @@ type WorktreeInitOptions = {
dbPort?: number;
seed?: boolean;
seedMode?: string;
preserveLiveWork?: boolean;
force?: boolean;
};
@@ -120,30 +116,6 @@ type WorktreeMergeHistoryOptions = {
yes?: boolean;
};
type WorktreeReseedOptions = {
from?: string;
to?: string;
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
seedMode?: string;
preserveLiveWork?: boolean;
yes?: boolean;
allowLiveTarget?: boolean;
};
type WorktreeRepairOptions = {
branch?: string;
home?: string;
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
seedMode?: string;
preserveLiveWork?: boolean;
noSeed?: boolean;
allowLiveTarget?: boolean;
};
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
@@ -182,8 +154,6 @@ type CopiedGitHooksResult = {
type SeedWorktreeDatabaseResult = {
backupSummary: string;
pausedScheduledRoutines: number;
executionQuarantine: SeededWorktreeExecutionQuarantineSummary;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
@@ -191,14 +161,6 @@ type SeedWorktreeDatabaseResult = {
}>;
};
export type SeededWorktreeExecutionQuarantineSummary = {
disabledTimerHeartbeats: number;
resetRunningAgents: number;
quarantinedInProgressIssues: number;
unassignedTodoIssues: number;
unassignedReviewIssues: number;
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -211,18 +173,6 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
}
function formatSeededWorktreeExecutionQuarantineSummary(
summary: SeededWorktreeExecutionQuarantineSummary,
): string {
return [
`disabled timer heartbeats: ${summary.disabledTimerHeartbeats}`,
`reset running agents: ${summary.resetRunningAgents}`,
`quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`,
`unassigned todo issues: ${summary.unassignedTodoIssues}`,
`unassigned review issues: ${summary.unassignedReviewIssues}`,
].join(", ");
}
const WORKTREE_NAME_PREFIX = "paperclip-";
function resolveWorktreeMakeName(name: string): string {
@@ -586,46 +536,6 @@ function detectGitBranchName(cwd: string): string | null {
}
}
function validateGitBranchName(cwd: string, branchName: string): string {
const value = nonEmpty(branchName);
if (!value) {
throw new Error("Branch name is required.");
}
try {
execFileSync("git", ["check-ref-format", "--branch", value], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`);
}
return value;
}
function isPrimaryGitWorktree(cwd: string): boolean {
const workspace = detectGitWorkspaceInfo(cwd);
return Boolean(workspace && workspace.gitDir === workspace.commonDir);
}
function resolvePrimaryGitRepoRoot(cwd: string): string {
const workspace = detectGitWorkspaceInfo(cwd);
if (!workspace) {
throw new Error("Current directory is not inside a git repository.");
}
if (workspace.gitDir === workspace.commonDir) {
return workspace.root;
}
return path.resolve(workspace.commonDir, "..");
}
function resolveRepairWorktreeDirName(branchName: string): string {
const normalized = branchName.trim()
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-._]+|[-._]+$/g, "");
return normalized || "worktree";
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
@@ -811,179 +721,6 @@ export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
}
export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource {
const fromSelector = nonEmpty(input.from);
const fromConfig = nonEmpty(input.fromConfig);
const fromDataDir = nonEmpty(input.fromDataDir);
const fromInstance = nonEmpty(input.fromInstance);
const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance);
if (fromSelector && hasExplicitConfigSource) {
throw new Error(
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
);
}
if (fromSelector) {
const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true });
return {
configPath: endpoint.configPath,
label: endpoint.label,
};
}
if (hasExplicitConfigSource) {
const configPath = resolveSourceConfigPath({
fromConfig: fromConfig ?? undefined,
fromDataDir: fromDataDir ?? undefined,
fromInstance: fromInstance ?? undefined,
});
return {
configPath,
label: configPath,
};
}
throw new Error(
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
);
}
function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource {
const fromConfig = nonEmpty(input.fromConfig);
const fromDataDir = nonEmpty(input.fromDataDir);
const fromInstance = nonEmpty(input.fromInstance) ?? "default";
const configPath = resolveSourceConfigPath({
fromConfig: fromConfig ?? undefined,
fromDataDir: fromDataDir ?? undefined,
fromInstance,
});
return {
configPath,
label: configPath,
};
}
export function resolveWorktreeReseedTargetPaths(input: {
configPath: string;
rootPath: string;
}): WorktreeLocalPaths {
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(input.configPath));
const homeDir = nonEmpty(envEntries.PAPERCLIP_HOME);
const instanceId = nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID);
if (!homeDir || !instanceId) {
throw new Error(
`Target config ${input.configPath} does not look like a worktree-local Paperclip instance. Expected PAPERCLIP_HOME and PAPERCLIP_INSTANCE_ID in the adjacent .env.`,
);
}
return resolveWorktreeLocalPaths({
cwd: input.rootPath,
homeDir,
instanceId,
});
}
function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null {
const trimmed = selector.trim();
if (trimmed.length === 0) return null;
const directPath = path.resolve(trimmed);
if (existsSync(directPath)) {
return {
worktree: directPath,
branch: null,
branchLabel: path.basename(directPath),
hasPaperclipConfig: existsSync(path.resolve(directPath, ".paperclip", "config.json")),
isCurrent: directPath === path.resolve(cwd),
};
}
return toMergeSourceChoices(cwd).find((choice) =>
choice.worktree === directPath
|| path.basename(choice.worktree) === trimmed
|| choice.branchLabel === trimmed
|| choice.branch === trimmed,
) ?? null;
}
async function ensureRepairTargetWorktree(input: {
selector?: string;
seedMode: WorktreeSeedMode;
opts: WorktreeRepairOptions;
}): Promise<ResolvedWorktreeRepairTarget | null> {
const cwd = process.cwd();
const currentRoot = path.resolve(cwd);
const currentConfigPath = path.resolve(currentRoot, ".paperclip", "config.json");
if (!input.selector) {
if (isPrimaryGitWorktree(cwd)) {
return null;
}
return {
rootPath: currentRoot,
configPath: currentConfigPath,
label: path.basename(currentRoot),
branchName: detectGitBranchName(cwd),
created: false,
};
}
const existing = resolveExistingGitWorktree(input.selector, cwd);
if (existing) {
return {
rootPath: existing.worktree,
configPath: path.resolve(existing.worktree, ".paperclip", "config.json"),
label: existing.branchLabel,
branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel,
created: false,
};
}
const repoRoot = resolvePrimaryGitRepoRoot(cwd);
const branchName = validateGitBranchName(repoRoot, input.selector);
const targetPath = path.resolve(
repoRoot,
".paperclip",
"worktrees",
resolveRepairWorktreeDirName(branchName),
);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`);
}
mkdirSync(path.dirname(targetPath), { recursive: true });
const spinner = p.spinner();
spinner.start(`Creating git worktree for ${branchName}...`);
try {
execFileSync("git", resolveGitWorktreeAddArgs({
branchName,
targetPath,
branchExists: localBranchExists(repoRoot, branchName),
}), {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Created git worktree at ${targetPath}.`);
} catch (error) {
spinner.stop(pc.red("Failed to create git worktree."));
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
installDependenciesBestEffort(targetPath);
return {
rootPath: targetPath,
configPath: path.resolve(targetPath, ".paperclip", "config.json"),
label: branchName,
branchName,
created: true,
};
}
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
if (config.database.mode === "postgres") {
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
@@ -1114,163 +851,6 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
};
}
export async function pauseSeededScheduledRoutines(connectionString: string): Promise<number> {
const db = createDb(connectionString);
try {
const scheduledRoutineIds = await db
.selectDistinct({ routineId: routineTriggers.routineId })
.from(routineTriggers)
.where(and(eq(routineTriggers.kind, "schedule"), eq(routineTriggers.enabled, true)));
const idsToPause = scheduledRoutineIds
.map((row) => row.routineId)
.filter((value): value is string => Boolean(value));
if (idsToPause.length === 0) {
return 0;
}
const paused = await db
.update(routines)
.set({
status: "paused",
updatedAt: new Date(),
})
.where(and(inArray(routines.id, idsToPause), sql`${routines.status} <> 'paused'`, sql`${routines.status} <> 'archived'`))
.returning({ id: routines.id });
return paused.length;
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutionQuarantineSummary = {
disabledTimerHeartbeats: 0,
resetRunningAgents: 0,
quarantinedInProgressIssues: 0,
unassignedTodoIssues: 0,
unassignedReviewIssues: 0,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isEnabledValue(value: unknown): boolean {
return value === true || value === "true" || value === 1 || value === "1";
}
function normalizeWorktreeRuntimeConfig(runtimeConfig: unknown): {
runtimeConfig: Record<string, unknown>;
disabledTimerHeartbeat: boolean;
changed: boolean;
} {
const nextRuntimeConfig = isRecord(runtimeConfig) ? { ...runtimeConfig } : {};
const heartbeat = isRecord(nextRuntimeConfig.heartbeat) ? { ...nextRuntimeConfig.heartbeat } : null;
if (!heartbeat) {
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
}
const disabledTimerHeartbeat = isEnabledValue(heartbeat.enabled);
if (heartbeat.enabled !== false) {
heartbeat.enabled = false;
nextRuntimeConfig.heartbeat = heartbeat;
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat, changed: true };
}
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
}
export async function quarantineSeededWorktreeExecutionState(
connectionString: string,
): Promise<SeededWorktreeExecutionQuarantineSummary> {
const db = createDb(connectionString);
const summary = { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY };
try {
await db.transaction(async (tx) => {
const seededAgents = await tx
.select({
id: agents.id,
status: agents.status,
runtimeConfig: agents.runtimeConfig,
})
.from(agents);
for (const agent of seededAgents) {
const normalized = normalizeWorktreeRuntimeConfig(agent.runtimeConfig);
const nextStatus = agent.status === "running" ? "idle" : agent.status;
if (normalized.disabledTimerHeartbeat) {
summary.disabledTimerHeartbeats += 1;
}
if (agent.status === "running") {
summary.resetRunningAgents += 1;
}
if (normalized.changed || nextStatus !== agent.status) {
await tx
.update(agents)
.set({
runtimeConfig: normalized.runtimeConfig,
status: nextStatus,
updatedAt: new Date(),
})
.where(eq(agents.id, agent.id));
}
}
const affectedIssues = await tx
.select({
id: issues.id,
companyId: issues.companyId,
status: issues.status,
})
.from(issues)
.where(
and(
sql`${issues.assigneeAgentId} is not null`,
sql`${issues.assigneeUserId} is null`,
inArray(issues.status, ["todo", "in_progress", "in_review"]),
),
);
for (const issue of affectedIssues) {
const nextStatus = issue.status === "in_progress" ? "blocked" : issue.status;
await tx
.update(issues)
.set({
status: nextStatus,
assigneeAgentId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
executionWorkspaceId: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
if (issue.status === "in_progress") {
summary.quarantinedInProgressIssues += 1;
await tx.insert(issueComments).values({
companyId: issue.companyId,
issueId: issue.id,
body:
"Quarantined during worktree seed so copied in-flight work does not auto-run in this isolated instance. " +
"Reassign or unblock here only if you intentionally want the worktree instance to own this task.",
});
} else if (issue.status === "todo") {
summary.unassignedTodoIssues += 1;
} else if (issue.status === "in_review") {
summary.unassignedReviewIssues += 1;
}
}
});
return summary;
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
async function seedWorktreeDatabase(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
@@ -1278,7 +858,6 @@ async function seedWorktreeDatabase(input: {
targetPaths: WorktreeLocalPaths;
instanceId: string;
seedMode: WorktreeSeedMode;
preserveLiveWork?: boolean;
}): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
@@ -1298,8 +877,6 @@ async function seedWorktreeDatabase(input: {
input.sourceConfig.database.embeddedPostgresDataDir,
input.sourceConfig.database.embeddedPostgresPort,
);
const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`;
await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip");
}
const sourceConnectionString = resolveSourceConnectionString(
input.sourceConfig,
@@ -1309,7 +886,7 @@ async function seedWorktreeDatabase(input: {
const backup = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
retentionDays: 7,
filenamePrefix: `${input.instanceId}-seed`,
includeMigrationJournal: true,
excludeTables: seedPlan.excludedTables,
@@ -1329,10 +906,6 @@ async function seedWorktreeDatabase(input: {
backupFile: backup.backupFile,
});
await applyPendingMigrations(targetConnectionString);
const executionQuarantine = input.preserveLiveWork
? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }
: await quarantineSeededWorktreeExecutionState(targetConnectionString);
const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
@@ -1340,8 +913,6 @@ async function seedWorktreeDatabase(input: {
return {
backupSummary: formatDatabaseBackupResult(backup),
pausedScheduledRoutines,
executionQuarantine,
reboundWorkspaces,
};
} finally {
@@ -1371,8 +942,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
instanceId,
});
const branding = {
name: opts.name ?? worktreeName,
color: opts.color ?? generateWorktreeColor(),
name: worktreeName,
color: generateWorktreeColor(),
};
const sourceConfigPath = resolveSourceConfigPath(opts);
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
@@ -1420,8 +991,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
let seedSummary: string | null = null;
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
let pausedScheduledRoutineCount: number | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!sourceConfig) {
@@ -1439,11 +1008,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
targetPaths: paths,
instanceId,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
});
seedSummary = seeded.backupSummary;
seedExecutionQuarantineSummary = seeded.executionQuarantine;
pausedScheduledRoutineCount = seeded.pausedScheduledRoutines;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) {
@@ -1466,16 +1032,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
if (opts.preserveLiveWork) {
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
} else if (seedExecutionQuarantineSummary) {
p.log.message(
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seedExecutionQuarantineSummary)}`),
);
}
if (pausedScheduledRoutineCount != null) {
p.log.message(pc.dim(`Paused scheduled routines: ${pausedScheduledRoutineCount}`));
}
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
@@ -1543,7 +1099,18 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
installDependenciesBestEffort(targetPath);
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
const originalCwd = process.cwd();
try {
@@ -1560,21 +1127,6 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
}
}
function installDependenciesBestEffort(targetPath: string): void {
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
}
type WorktreeCleanupOptions = {
instance?: string;
home?: string;
@@ -1603,19 +1155,6 @@ type ResolvedWorktreeEndpoint = {
isCurrent: boolean;
};
type ResolvedWorktreeReseedSource = {
configPath: string;
label: string;
};
type ResolvedWorktreeRepairTarget = {
rootPath: string;
configPath: string;
label: string;
branchName: string | null;
created: boolean;
};
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd,
@@ -2109,13 +1648,6 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
return lines.join("\n");
}
function resolveRunningEmbeddedPostgresPid(config: PaperclipConfig): number | null {
if (config.database.mode !== "embedded-postgres") {
return null;
}
return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid"));
}
async function collectMergePlan(input: {
sourceDb: ClosableDb;
targetDb: ClosableDb;
@@ -3057,187 +2589,6 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
}
}
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
const seedMode = opts.seedMode ?? "full";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const targetEndpoint = opts.to
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
: resolveCurrentEndpoint();
const source = resolveWorktreeReseedSource(opts);
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to values.");
}
if (!existsSync(source.configPath)) {
throw new Error(`Source config not found at ${source.configPath}.`);
}
const targetConfig = readConfig(targetEndpoint.configPath);
if (!targetConfig) {
throw new Error(`Target config not found at ${targetEndpoint.configPath}.`);
}
const sourceConfig = readConfig(source.configPath);
if (!sourceConfig) {
throw new Error(`Source config not found at ${source.configPath}.`);
}
const targetPaths = resolveWorktreeReseedTargetPaths({
configPath: targetEndpoint.configPath,
rootPath: targetEndpoint.rootPath,
});
const runningTargetPid = resolveRunningEmbeddedPostgresPid(targetConfig);
if (runningTargetPid && !opts.allowLiveTarget) {
throw new Error(
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${targetEndpoint.rootPath} before reseeding, or re-run with --allow-live-target if you want to override this guard.`,
);
}
const confirmed = opts.yes
? true
: await p.confirm({
message: `Overwrite the isolated Paperclip DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
p.log.warn("Reseed cancelled.");
return;
}
if (runningTargetPid && opts.allowLiveTarget) {
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
}
const spinner = p.spinner();
spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`);
try {
const seeded = await seedWorktreeDatabase({
sourceConfigPath: source.configPath,
sourceConfig,
targetConfig,
targetPaths,
instanceId: targetPaths.instanceId,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
});
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
p.log.message(pc.dim(`Source: ${source.configPath}`));
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
if (opts.preserveLiveWork) {
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
} else {
p.log.message(
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seeded.executionQuarantine)}`),
);
}
p.log.message(pc.dim(`Paused scheduled routines: ${seeded.pausedScheduledRoutines}`));
for (const rebound of seeded.reboundWorkspaces) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
);
}
p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`));
} catch (error) {
spinner.stop(pc.red("Failed to reseed worktree database."));
throw error;
}
}
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
await runWorktreeReseed(opts);
}
export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree repair ")));
const seedMode = opts.seedMode ?? "minimal";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const target = await ensureRepairTargetWorktree({
selector: nonEmpty(opts.branch) ?? undefined,
seedMode,
opts,
});
if (!target) {
p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.");
p.outro(pc.yellow("No worktree repaired."));
return;
}
const source = resolveWorktreeRepairSource(opts);
if (!existsSync(source.configPath)) {
throw new Error(`Source config not found at ${source.configPath}.`);
}
if (path.resolve(source.configPath) === path.resolve(target.configPath)) {
throw new Error("Source and target Paperclip configs are the same. Use --from-config/--from-instance to point repair at a different source.");
}
const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null;
const targetEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(target.configPath));
const targetHasWorktreeEnv = Boolean(
nonEmpty(targetEnvEntries.PAPERCLIP_HOME) && nonEmpty(targetEnvEntries.PAPERCLIP_INSTANCE_ID),
);
if (targetConfig && targetHasWorktreeEnv && opts.noSeed) {
p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`));
p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`));
return;
}
if (targetConfig && targetHasWorktreeEnv) {
await runWorktreeReseed({
fromConfig: source.configPath,
to: target.rootPath,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
yes: true,
allowLiveTarget: opts.allowLiveTarget,
});
return;
}
const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath));
const repairPaths = resolveWorktreeLocalPaths({
cwd: target.rootPath,
homeDir: resolveWorktreeHome(opts.home),
instanceId: repairInstanceId,
});
const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"));
if (runningTargetPid && !opts.allowLiveTarget) {
throw new Error(
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`,
);
}
if (runningTargetPid && opts.allowLiveTarget) {
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
}
const originalCwd = process.cwd();
try {
process.chdir(target.rootPath);
await runWorktreeInit({
home: opts.home,
fromConfig: source.configPath,
fromDataDir: opts.fromDataDir,
fromInstance: opts.fromInstance,
seed: opts.noSeed ? false : true,
seedMode,
preserveLiveWork: opts.preserveLiveWork,
force: true,
});
} finally {
process.chdir(originalCwd);
}
}
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
@@ -3254,7 +2605,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -3271,7 +2621,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
@@ -3302,34 +2651,6 @@ export function registerWorktreeCommands(program: Command): void {
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
.action(worktreeMergeHistoryCommand);
worktree
.command("reseed")
.description("Re-seed an existing worktree-local instance from another Paperclip instance or worktree")
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--yes", "Skip the destructive confirmation prompt", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeReseedCommand);
worktree
.command("repair")
.description("Create or repair a linked worktree-local Paperclip instance without touching the primary checkout")
.option("--branch <name>", "Existing branch/worktree selector to repair, or a branch name to create under .paperclip/worktrees")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeRepairCommand);
program
.command("worktree:cleanup")
.description("Safely remove a worktree, its branch, and its isolated instance data")

View File

@@ -1,183 +0,0 @@
import { execFileSync } from "node:child_process";
import {
ALL_INTERFACES_BIND_HOST,
LOOPBACK_BIND_HOST,
inferBindModeFromHost,
isAllInterfacesHost,
isLoopbackHost,
type BindMode,
type DeploymentExposure,
type DeploymentMode,
} from "@paperclipai/shared";
import type { AuthConfig, ServerConfig } from "./schema.js";
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
type BaseServerInput = {
port: number;
allowedHostnames: string[];
serveUi: boolean;
};
export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
if (server?.bind) return server.bind;
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
}
export function detectTailnetBindHost(): string | undefined {
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
if (explicit) return explicit;
try {
const stdout = execFileSync("tailscale", ["ip", "-4"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
});
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
} catch {
return undefined;
}
}
export function buildPresetServerConfig(
bind: Exclude<BindMode, "custom">,
input: BaseServerInput,
): { server: ServerConfig; auth: AuthConfig } {
const host =
bind === "loopback"
? LOOPBACK_BIND_HOST
: bind === "tailnet"
? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST)
: ALL_INTERFACES_BIND_HOST;
return {
server: {
deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
exposure: "private",
bind,
customBindHost: undefined,
host,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
};
}
export function buildCustomServerConfig(input: BaseServerInput & {
deploymentMode: DeploymentMode;
exposure: DeploymentExposure;
host: string;
publicBaseUrl?: string;
}): { server: ServerConfig; auth: AuthConfig } {
const normalizedHost = input.host.trim();
const bind = isLoopbackHost(normalizedHost)
? "loopback"
: isAllInterfacesHost(normalizedHost)
? "lan"
: "custom";
return {
server: {
deploymentMode: input.deploymentMode,
exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
bind,
customBindHost: bind === "custom" ? normalizedHost : undefined,
host: normalizedHost,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
},
auth:
input.deploymentMode === "authenticated" && input.exposure === "public"
? {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: input.publicBaseUrl,
}
: {
baseUrlMode: "auto",
disableSignUp: false,
},
};
}
export function resolveQuickstartServerConfig(input: {
bind?: BindMode | null;
deploymentMode?: DeploymentMode | null;
exposure?: DeploymentExposure | null;
host?: string | null;
port: number;
allowedHostnames: string[];
serveUi: boolean;
publicBaseUrl?: string;
}): { server: ServerConfig; auth: AuthConfig } {
const trimmedHost = input.host?.trim();
const explicitBind = input.bind ?? null;
if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
return buildPresetServerConfig(explicitBind, {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}
if (explicitBind === "custom") {
return buildCustomServerConfig({
deploymentMode: input.deploymentMode ?? "authenticated",
exposure: input.exposure ?? "private",
host: trimmedHost || LOOPBACK_BIND_HOST,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
if (trimmedHost) {
return buildCustomServerConfig({
deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
exposure: input.exposure ?? "private",
host: trimmedHost,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
if (input.deploymentMode === "authenticated") {
if (input.exposure === "public") {
return buildCustomServerConfig({
deploymentMode: "authenticated",
exposure: "public",
host: ALL_INTERFACES_BIND_HOST,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
return buildPresetServerConfig("lan", {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}
return buildPresetServerConfig("loopback", {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}

View File

@@ -24,6 +24,7 @@ import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
import { cliVersion } from "./version.js";
import { registerBoardCommands } from "./commands/client/board.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -50,8 +51,7 @@ program
.description("Interactive first-run setup wizard")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
.option("--run", "Start Paperclip immediately after saving config", false)
.action(onboard);
@@ -109,7 +109,6 @@ program
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-i, --instance <id>", "Local instance id (default: default)")
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
.option("--repair", "Attempt automatic repairs during doctor", true)
.option("--no-repair", "Disable automatic repairs during doctor")
.action(runCommand);
@@ -148,6 +147,7 @@ registerRoutineCommands(program);
registerFeedbackCommands(program);
registerWorktreeCommands(program);
registerPluginCommands(program);
registerBoardCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");

View File

@@ -1,16 +1,6 @@
import * as p from "@clack/prompts";
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
import type { AuthConfig, ServerConfig } from "../config/schema.js";
import { parseHostnameCsv } from "../config/hostnames.js";
import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js";
const TAILNET_BIND_WARNING =
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
function cancelled(): never {
p.cancel("Setup cancelled.");
process.exit(0);
}
export async function promptServer(opts?: {
currentServer?: Partial<ServerConfig>;
@@ -18,37 +8,69 @@ export async function promptServer(opts?: {
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
const currentServer = opts?.currentServer;
const currentAuth = opts?.currentAuth;
const currentBind = inferConfiguredBind(currentServer);
const bindSelection = await p.select({
message: "Reachability",
const deploymentModeSelection = await p.select({
message: "Deployment mode",
options: [
{
value: "loopback" as const,
label: "Trusted local",
hint: "Recommended for first run: localhost only, no login friction",
value: "local_trusted",
label: "Local trusted",
hint: "Easiest for local setup (no login, localhost-only)",
},
{
value: "lan" as const,
label: "Private network",
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
},
{
value: "tailnet" as const,
label: "Tailnet",
hint: "Private authenticated access using the machine's detected Tailscale address",
},
{
value: "custom" as const,
label: "Custom",
hint: "Choose exact auth mode, exposure, and host manually",
value: "authenticated",
label: "Authenticated",
hint: "Login required; use for private network or public hosting",
},
],
initialValue: currentBind,
initialValue: currentServer?.deploymentMode ?? "local_trusted",
});
if (p.isCancel(bindSelection)) cancelled();
const bind = bindSelection as BindMode;
if (p.isCancel(deploymentModeSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
let exposure: ServerConfig["exposure"] = "private";
if (deploymentMode === "authenticated") {
const exposureSelection = await p.select({
message: "Exposure profile",
options: [
{
value: "private",
label: "Private network",
hint: "Private access (for example Tailscale), lower setup friction",
},
{
value: "public",
label: "Public internet",
hint: "Internet-facing deployment with stricter requirements",
},
],
initialValue: currentServer?.exposure ?? "private",
});
if (p.isCancel(exposureSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
exposure = exposureSelection as ServerConfig["exposure"];
}
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
const hostStr = await p.text({
message: "Bind host",
defaultValue: currentServer?.host ?? hostDefault,
placeholder: hostDefault,
validate: (val) => {
if (!val.trim()) return "Host is required";
},
});
if (p.isCancel(hostStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const portStr = await p.text({
message: "Server port",
@@ -62,113 +84,15 @@ export async function promptServer(opts?: {
},
});
if (p.isCancel(portStr)) cancelled();
const port = Number(portStr) || 3100;
const serveUi = currentServer?.serveUi ?? true;
if (bind === "loopback") {
return buildPresetServerConfig("loopback", {
port,
allowedHostnames: [],
serveUi,
});
if (p.isCancel(portStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (bind === "lan" || bind === "tailnet") {
const allowedHostnamesInput = await p.text({
message: "Allowed private hostnames (comma-separated, optional)",
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
placeholder:
bind === "tailnet"
? "your-machine.tailnet.ts.net"
: "dotta-macbook-pro, host.docker.internal",
validate: (val) => {
try {
parseHostnameCsv(val);
return;
} catch (err) {
return err instanceof Error ? err.message : "Invalid hostname list";
}
},
});
if (p.isCancel(allowedHostnamesInput)) cancelled();
const preset = buildPresetServerConfig(bind, {
port,
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
serveUi,
});
if (bind === "tailnet" && isLoopbackHost(preset.server.host)) {
p.log.warn(TAILNET_BIND_WARNING);
}
return preset;
}
const deploymentModeSelection = await p.select({
message: "Auth mode",
options: [
{
value: "local_trusted",
label: "Local trusted",
hint: "No login required; only safe with loopback-only or similarly trusted access",
},
{
value: "authenticated",
label: "Authenticated",
hint: "Login required; supports both private-network and public deployments",
},
],
initialValue: currentServer?.deploymentMode ?? "authenticated",
});
if (p.isCancel(deploymentModeSelection)) cancelled();
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
let exposure: ServerConfig["exposure"] = "private";
if (deploymentMode === "authenticated") {
const exposureSelection = await p.select({
message: "Exposure profile",
options: [
{
value: "private",
label: "Private network",
hint: "Private access only, with automatic URL handling",
},
{
value: "public",
label: "Public internet",
hint: "Internet-facing deployment with explicit public URL requirements",
},
],
initialValue: currentServer?.exposure ?? "private",
});
if (p.isCancel(exposureSelection)) cancelled();
exposure = exposureSelection as ServerConfig["exposure"];
}
const defaultHost =
currentServer?.customBindHost ??
currentServer?.host ??
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
const host = await p.text({
message: "Bind host",
defaultValue: defaultHost,
placeholder: defaultHost,
validate: (val) => {
if (!val.trim()) return "Host is required";
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
return "Local trusted mode requires a loopback host such as 127.0.0.1";
}
},
});
if (p.isCancel(host)) cancelled();
let allowedHostnames: string[] = [];
if (deploymentMode === "authenticated" && exposure === "private") {
const allowedHostnamesInput = await p.text({
message: "Allowed private hostnames (comma-separated, optional)",
message: "Allowed hostnames (comma-separated, optional)",
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
validate: (val) => {
@@ -181,11 +105,15 @@ export async function promptServer(opts?: {
},
});
if (p.isCancel(allowedHostnamesInput)) cancelled();
if (p.isCancel(allowedHostnamesInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
}
let publicBaseUrl: string | undefined;
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
@@ -205,17 +133,32 @@ export async function promptServer(opts?: {
}
},
});
if (p.isCancel(urlInput)) cancelled();
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
if (p.isCancel(urlInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
}
return buildCustomServerConfig({
deploymentMode,
exposure,
host: host.trim(),
port,
allowedHostnames,
serveUi,
publicBaseUrl,
});
return {
server: {
deploymentMode,
exposure,
host: hostStr.trim(),
port,
allowedHostnames,
serveUi: currentServer?.serveUi ?? true,
},
auth,
};
}

177
doc/BOARD-CHAT-GUIDE.md Normal file
View File

@@ -0,0 +1,177 @@
# Board Chat Guide
A step-by-step guide for managing your Paperclip company through Claude Code in the terminal.
## What is this?
Paperclip is a control plane for AI-agent companies. You create a company, hire AI agents, assign them tasks, and manage their work. Normally you'd do this through the web dashboard, but the **Board Chat** skill lets you do everything through a natural conversation with Claude in your terminal.
Think of it like texting an assistant who happens to have full access to your company's operations.
## Prerequisites
Before you start, you need two things:
1. **Paperclip running locally** — Ask your engineer to set this up. They'll run `pnpm dev` and tell you the URL (usually `http://localhost:3000`).
2. **Claude Code installed** — This is Anthropic's CLI tool. Install it by running:
```
npm install -g @anthropic-ai/claude-code
```
## Setup (one time, ~2 minutes)
### Step 1: Install the board skill
Open your terminal and navigate to the Paperclip project folder:
```
cd ~/Projects/DEV/paperclip
```
Run the setup command:
```
pnpm paperclipai board setup
```
This does two things:
- Installs the board skill so Claude knows how to manage Paperclip
- Shows you which companies exist (if any)
### Step 2: Set your environment
The setup command prints one or two lines starting with `export`. Copy and paste them into your terminal:
```
export PAPERCLIP_API_URL='http://localhost:3000'
```
If you already have a company, it will also show:
```
export PAPERCLIP_COMPANY_ID='your-company-id-here'
```
Paste these lines and press Enter. They tell Claude where your Paperclip server is.
### Step 3: Launch Claude Code
```
claude --dangerously-skip-permissions
```
The `--dangerously-skip-permissions` flag lets Claude run commands without asking you to approve each one. This is safe because it's only talking to your local Paperclip server.
That's it. You're in. Start typing.
## Your first conversation
### Starting a new company
```
You: I want to start a new company called Megacorp. Our mission is to
build the best widget marketplace on the internet.
```
Claude will create the company and guide you through setting up your first CEO agent.
### If you already have a company
```
You: What's happening today?
```
Claude will show you a dashboard: how many agents you have, open tasks, budget usage, and anything that needs your attention.
## Common things you can ask
### Company overview
- "What's the status of my company?"
- "Show me the dashboard"
- "How much have we spent this month?"
### Hiring agents
- "Help me build a hiring plan"
- "I need a frontend engineer and a content writer"
- "Show me the candidates' system prompts"
- "Approve all hires"
### Managing tasks
- "What tasks are open?"
- "What's the CEO working on?"
- "Create a task to build a landing page and assign it to the frontend engineer"
### Approvals
- "Are there any pending approvals?"
- "Approve the designer hire"
- "Reject the icon library request — too expensive"
### Costs
- "How are my costs today?"
- "Show me a breakdown by agent"
### Agent management
- "Show me all my agents"
- "What's the frontend engineer's system prompt?"
- "Change the designer's focus to include UX research"
## Tips
### Be natural
You don't need to use special commands or syntax. Just talk like you're chatting with a colleague. Claude understands context.
### Iterate on plans
When building a hiring plan or strategy, you can go back and forth:
```
You: Cut the SEO specialist. Add a designer instead.
You: Actually, make the designer focus on UX research too.
You: Looks good. Hire them all.
```
### Check the web UI
Everything Claude does through chat is also visible in the Paperclip web dashboard. Go to `http://localhost:3000` in your browser to see the spatial view of your company — org chart, task board, cost graphs.
### Session continuity
When you close the terminal and come back later, Claude won't remember your previous conversation. But it will read the decision log and check the dashboard, so it knows the current state of your company.
Start a new session the same way:
```
export PAPERCLIP_API_URL='http://localhost:3000'
export PAPERCLIP_COMPANY_ID='your-company-id'
claude --dangerously-skip-permissions
```
Then just say "What's happening?" and pick up where you left off.
### Editing across surfaces
You can edit things (like hiring plans or agent prompts) in three places:
1. **In chat** — describe the change and Claude makes it
2. **In a file** — Claude can create local `.md` files you can edit in any text editor
3. **In the web UI** — edit directly in the dashboard, then tell Claude "sync up"
## Troubleshooting
### "PAPERCLIP_API_URL is not set"
You forgot to run the `export` command. Paste it again:
```
export PAPERCLIP_API_URL='http://localhost:3000'
```
### Claude keeps asking for permission to run commands
You launched Claude without the permissions flag. Exit with Ctrl+C and relaunch:
```
claude --dangerously-skip-permissions
```
### Nothing happens / commands fail
The Paperclip server probably isn't running. Ask your engineer to start it with `pnpm dev`.
### Claude seems confused about my company
Start fresh by telling Claude your company ID:
```
You: My company ID is abc123-def456. Show me the dashboard.
```
## What's next
Once you're comfortable with the terminal experience, you can also try the **Board Chat** in the web UI — go to `http://localhost:3000` and click "Board Chat" in the sidebar. Same conversation, but inside the dashboard where you can see your agents and tasks alongside the chat.

View File

@@ -32,12 +32,10 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
Current CLI behavior:
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
- server onboarding/configure ask for reachability intent and write `server.bind`
- `paperclipai run --bind <loopback|lan|tailnet>` passes a quickstart bind preset into first-run onboarding when config is missing
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag
- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag
Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`.
Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5.
Allow an authenticated/private hostname (for example custom Tailscale DNS):

View File

@@ -27,18 +27,6 @@ pnpm db:migrate
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
Issue reference mentions follow the normal migration path: the schema migration creates the tracking table, but it does not backfill historical issue titles, descriptions, comments, or documents automatically.
To backfill existing content manually after migrating, run:
```sh
pnpm issue-references:backfill
# optional: limit to one company
pnpm issue-references:backfill -- --company <company-id>
```
Future issue, comment, and document writes sync references automatically without running the backfill command.
This mode is ideal for local development and one-command installs.
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
@@ -106,16 +94,6 @@ Set `DATABASE_URL` in your `.env`:
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
```
For hosted deployments that use a pooled runtime URL, set
`DATABASE_MIGRATION_URL` to the direct connection URL. Paperclip uses it for
startup schema checks/migrations and plugin namespace migrations, while the app
continues to use `DATABASE_URL` for runtime queries:
```sh
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
DATABASE_MIGRATION_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
```
If using connection pooling (port 6543), the `postgres` client must disable prepared statements. Update `packages/db/src/client.ts`:
```ts

View File

@@ -17,11 +17,6 @@ Paperclip supports two runtime modes:
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
Paperclip now treats **bind** as a separate concern from auth:
- auth model: `local_trusted` vs `authenticated`, plus `private/public`
- reachability model: `server.bind = loopback | lan | tailnet | custom`
## 2. Canonical Model
| Runtime Mode | Exposure | Human auth | Primary use |
@@ -30,15 +25,6 @@ Paperclip now treats **bind** as a separate concern from auth:
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
## Reachability Model
| Bind | Meaning | Typical use |
|---|---|---|
| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments |
| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access |
| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access |
| `custom` | Listen on an explicit host/IP | advanced interface-specific setups |
## 3. Security Policy
## `local_trusted`
@@ -52,14 +38,12 @@ Paperclip now treats **bind** as a separate concern from auth:
- login required
- low-friction URL handling (`auto` base URL mode)
- private-host trust policy required
- bind can be `loopback`, `lan`, `tailnet`, or `custom`
## `authenticated + public`
- login required
- explicit public URL required
- stricter deployment checks and failures in doctor
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
## 4. Onboarding UX Contract
@@ -71,22 +55,14 @@ pnpm paperclipai onboard
Server prompt behavior:
1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private`
2. advanced server setup asks reachability first:
- `Trusted local``bind=loopback`, `local_trusted/private`
- `Private network``bind=lan`, `authenticated/private`
- `Tailnet``bind=tailnet`, `authenticated/private`
- `Custom` → manual mode/exposure/host entry
3. raw host entry is only required for the `Custom` path
4. explicit public URL is only required for `authenticated + public`
Examples:
```sh
pnpm paperclipai onboard --yes
pnpm paperclipai onboard --yes --bind lan
pnpm paperclipai run --bind tailnet
```
1. ask mode, default `local_trusted`
2. option copy:
- `local_trusted`: "Easiest for local setup (no login, localhost-only)"
- `authenticated`: "Login required; use for private network or public hosting"
3. if `authenticated`, ask exposure:
- `private`: "Private network access (for example Tailscale), lower setup friction"
- `public`: "Internet-facing deployment, stricter security requirements"
4. ask explicit public URL only for `authenticated + public`
`configure --section server` follows the same interactive behavior.
@@ -142,4 +118,3 @@ This prevents lockout when a user migrates from long-running local trusted usage
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
- V1 contract: `doc/SPEC-implementation.md`
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
- invite/join state map: `doc/spec/invite-flow.md`

View File

@@ -43,17 +43,6 @@ This starts:
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
## Storybook
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
```sh
pnpm storybook
pnpm build-storybook
```
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Inspect or stop the current repo's managed dev runner:
```sh
@@ -65,54 +54,18 @@ pnpm dev:stop
Tailscale/private-auth dev mode:
```sh
pnpm dev --bind lan
```
This runs dev as `authenticated/private` with a private-network bind preset.
For Tailscale-only reachability on a detected tailnet address:
```sh
pnpm dev --bind tailnet
```
Legacy aliases still map to the old broad private-network behavior:
```sh
pnpm dev --tailscale-auth
pnpm dev --authenticated-private
```
This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access.
Allow additional private hostnames (for example custom Tailscale hostnames):
```sh
pnpm paperclipai allowed-hostname dotta-macbook-pro
```
## Test Commands
Use the cheap local default unless you are specifically working on browser flows:
```sh
pnpm test
```
`pnpm test` runs the Vitest suite only. For interactive Vitest watch mode use:
```sh
pnpm test:watch
```
Browser suites stay separate:
```sh
pnpm test:e2e
pnpm test:release-smoke
```
These browser suites are intended for targeted local verification and CI, not the default agent/human test command.
## One-Command Local Run
For a first-time local install, you can bootstrap and run in one command:
@@ -220,13 +173,9 @@ Seed modes:
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first.
Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines.
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
That repo-local env also sets:
@@ -235,8 +184,6 @@ That repo-local env also sets:
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
Authenticated worktree servers also use the `PAPERCLIP_INSTANCE_ID` value to scope Better Auth cookie names.
Browser cookies are shared by host rather than port, so this prevents logging into one `127.0.0.1:<port>` worktree from replacing another worktree server's session cookie.
Print shell exports explicitly when needed:
@@ -277,7 +224,7 @@ paperclipai worktree init --force
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
```sh
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
pnpm paperclipai worktree init --force --seed-mode minimal \
--name PAP-884-ai-commits-component \
--from-config ~/.paperclip/instances/default/config.json
@@ -285,66 +232,6 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
| Option | Description |
|---|---|
| `--branch <name>` | Existing branch/worktree selector to repair, or a branch name to create under `.paperclip/worktrees` |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
| `--from-instance <id>` | Source instance id when deriving the source config (default: `default`) |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config |
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
Examples:
```sh
# From inside a linked worktree, rebuild missing .paperclip metadata and reseed it from the default instance.
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
pnpm paperclipai worktree repair
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
cd /path/to/paperclip
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
```
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
| Option | Description |
|---|---|
| `--from <worktree>` | Source worktree path, directory name, branch name, or `current` |
| `--to <worktree>` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
| `--from-instance <id>` | Source instance id when deriving the source config |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
| `--yes` | Skip the destructive confirmation prompt |
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
Examples:
```sh
# From the main repo, reseed a worktree from the current default/master instance.
cd /path/to/paperclip
pnpm paperclipai worktree reseed \
--from current \
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
--seed-mode full \
--yes
# From inside a worktree, reseed it from the default instance config.
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
pnpm paperclipai worktree reseed \
--from-instance default \
--seed-mode full
```
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |

View File

@@ -3,7 +3,7 @@ Use this exact checklist.
1. Start Paperclip in auth mode.
```bash
cd <paperclip-repo-root>
pnpm dev --bind lan
pnpm dev --tailscale-auth
```
Then verify:
```bash

View File

@@ -184,11 +184,6 @@ Invariant: at least one root `company` level goal per company.
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
- `env` jsonb null (same secret-aware env binding format used by agent config)
Invariant:
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
## 7.6 `issues` (core task entity)
@@ -395,8 +390,6 @@ Side effects:
- entering `done` sets `completed_at`
- entering `cancelled` sets `cancelled_at`
Detailed ownership, execution, blocker, and crash-recovery semantics are documented in `doc/execution-semantics.md`.
## 8.3 Approval Status
- `pending -> approved | rejected | cancelled`
@@ -498,7 +491,7 @@ All endpoints are under `/api` and return JSON.
```json
{
"agentId": "uuid",
"expectedStatuses": ["todo", "backlog", "blocked", "in_review"]
"expectedStatuses": ["todo", "backlog", "blocked"]
}
```
@@ -619,7 +612,7 @@ Per-agent schedule fields in `adapter_config`:
- `enabled` boolean
- `intervalSec` integer (minimum 30)
- `maxConcurrentRuns` integer; new agents default to `5`
- `maxConcurrentRuns` fixed at `1` for V1
Scheduler must skip invocation when:

View File

@@ -1,122 +0,0 @@
# Paperclip DS Extraction — Review
- **Generated:** 2026-04-21
- **Repo SHA:** `a26e1288b627e82c554445732c7d844648e6b5e1`
- **Branch:** `sockmonster-ds-extraction`
- **Discovery config:** [`_discovery.json`](./_discovery.json)
- **Scope:** `ui/` (`@paperclipai/ui`). Plugin SDK (`packages/plugins/sdk/src/ui/`) treated as contract surface, not implementation surface.
This is the entry point. Everything else is linked from here. Contents are ordered by **expected human value**, not by stage.
---
## Bottom line
One finding sits upstream of most of the others — resolving it moves four pattern docs from "pending" to "codifiable" and unblocks the single biggest token gap.
> **The app has a canonical status/priority color catalog (`ui/src/lib/status-colors.ts`) that bypasses the DS token layer and uses raw Tailwind palette classes across 11 hues and ~24 status keys.** Status indicators (`StatusIcon`, `StatusBadge`, `PriorityIcon`, `agentStatusDot`), chart colors (`ActivityCharts.tsx`, hardcoded hex), budget severity indicators (`BudgetPolicyCard`, `BudgetIncidentCard`, `BudgetSidebarMarker`), and quota fills (`QuotaBar`) are **four distinct systems encoding the same red/amber/green severity concept**, none of which share DS tokens.
A `--signal-*` token family would collapse four surfaces onto one vocabulary and make [status-display.md](./patterns/status-display.md), [quota-display.md](./patterns/quota-display.md), and the severity-indicator pattern opportunity all codifiable. See [tokens-review.md §4](./tokens/tokens-review.md#4-status-colorsts-is-a-canonical-semantic-color-catalog-that-bypasses-the-ds) and [patterns-review.md §6](./patterns/patterns-review.md#6-severity-indicator-3-level-health-display--pattern-opportunity).
Three other findings are high-value but smaller in scope:
- **`destructive-foreground` has a buggy light-mode value** equal to `destructive` itself (would render invisible if anyone used it — nobody does, so the bug is masked). [tokens-review.md §2](./tokens/tokens-review.md#2-destructive-foreground-has-a-wrong-light-mode-value-and-is-unused)
- **13 color tokens are dead** (all 5 `chart-*`, all 8 `sidebar-*`). Consolidating would drop color-token count from 32 → 19. [tokens-review.md §1, §3](./tokens/tokens-review.md#1-chart--tokens-are-dead)
- **The radius scale is non-monotonic and under-specified** — 227 uses of `rounded-lg` / `rounded-xl` resolve to square corners because `--radius-lg` / `--radius-xl` = 0. Needs a founder call on whether this is intentional flat-design or a stale migration state. [tokens-review.md §Radius scale](./tokens/tokens-review.md#radius-scale--under-founder-review)
---
## Recommended review order
Sequenced so each step unblocks the next. Total time estimated ~23 hours.
| # | Read | Decide | Est. |
|---|---|---|---|
| 1 | [tokens-review.md §High-confidence drift](./tokens/tokens-review.md#high-confidence-drift-likely-should-be-fixed) | Scope the signal-token work. Confirm dead-token deletions (chart-*, sidebar-*, destructive-foreground). | **25 min** |
| 2 | [tokens-review.md §Radius scale](./tokens/tokens-review.md#radius-scale--under-founder-review) | One call: intentional flat lg/xl, or restore a monotonic scale. | **15 min** |
| 3 | [components-review.md §Likely duplicates](./components/components-review.md#likely-duplicates) | Nine duplicate families. For each, note "merge/keep/defer" — patterns flow from the decisions. | **30 min** |
| 4 | [components-review.md §Plugin SDK contract gap](./components/components-review.md#plugin-sdk-contract-gap) | Choose: fulfill the 9 missing contracts, shrink them, or hybrid. | **15 min** |
| 5 | [patterns-review.md §Variance across documented patterns](./patterns/patterns-review.md#variance-across-documented-patterns-whats-inconsistent-between-instances) | Look at the status-element variance in detail pages (four different treatments across eight pages). | **15 min** |
| 6 | [patterns-review.md §Paperclip-domain patterns](./patterns/patterns-review.md#paperclip-domain-patterns-worth-calling-out-opportunities-not-ratified-patterns) | Reality-check the run-transcript / heartbeat / metric-cell opportunities before any codify step. | **20 min** |
| 7 | [components-review.md §Naming inconsistencies](./components/components-review.md#naming-inconsistencies) | Lower priority — no decision required today, but at least skim. | **10 min** |
| 8 | [components-review.md §Story coverage gaps](./components/components-review.md#story-coverage-gaps) | Shadcn primitives missing from `foundations.stories.tsx` (`collapsible`, `dropdown-menu`, `avatar`, `skeleton`, `scroll-area`) is a small, targeted fix. | **10 min** |
---
## Confidence
### High confidence (probably correct, spot-check only)
- **32 color tokens** extracted from `ui/src/index.css` (19 semantic surfaces, 5 chart, 8 sidebar).
- **5 radius tokens**, with value + definition-site recorded.
- **Usage counts per color and radius token** computed by unioning Tailwind-utility occurrences and `var(--token)` references across `ui/src/**/*.{ts,tsx,css}` (excluding the definition file itself). Counts are rough by intent — within ±10%.
- **135 component files** enumerated, classified into 22 primitives / 64 composites / 47 standalones / 2 non-component utilities.
- **104 components** cross-referenced against 14 Storybook files via import-graph parsing.
- **50 pages** enumerated; per-page import set captured.
- **11 plugin SDK ambient components** enumerated with host-implementation status.
- **4 components confirmed as storybook-only** (0 production uses): `AccountingModelCard`, `AgentProperties`, `CompanySwitcher`, `ExecutionParticipantPicker`.
### Medium confidence (review carefully)
- **Duplicate-family flags.** Eight families surfaced ([components-review.md §Likely duplicates](./components/components-review.md#likely-duplicates)) are based on name parallelism and/or shared imports. The strongest signals (entity-creation dialogs, subscription panels) need a side-by-side diff to confirm merge-ability; this extraction didn't do that.
- **`BillerSpendCard` vs `FinanceBillerCard` as likely-true-duplicate.** Flagged per directive. Not confirmed without a diff.
- **Pattern instance counts.** The `detail-page` and `list-page` patterns were identified by import-set intersection, which is a proxy for structural similarity. A page can import a component and not actually render it in the expected position; pattern shape is inferred, not verified pixel-by-pixel.
- **CVA variant extraction for primitives.** Parsed 3 files successfully (`button`, `badge`, one more). The rest of the primitives likely have variants that the static parser missed.
- **The severity-indicator pattern ([patterns-review.md §6](./patterns/patterns-review.md#6-severity-indicator-3-level-health-display--pattern-opportunity)).** Named as an *opportunity*, not a ratified pattern — cross-system evidence is strong but the four systems weren't compared pixel-for-pixel; they may not actually agree on what "warning" looks like.
- **Story coverage set.** Computed by parsing imports in `.stories.tsx` files. A component that's imported by a story but never actually rendered would falsely appear covered. Low risk given story-file structure but not validated.
### Low confidence (likely wrong, incomplete, or judgment-heavy)
- **Motion tokens.** None exist as variables — motion is inline `@keyframes` + `cubic-bezier()`. Pattern docs don't describe motion. The 5 keyframes in `index.css` are listed; their callers are not cross-referenced.
- **Typography.** No project-local font/type tokens found — the section is near-empty because Tailwind v4 defaults carry the load plus `@tailwindcss/typography`. If there are intended type-scale conventions in components that weren't captured by token extraction, those are missed.
- **Elevation / shadows.** No tokens, so no inventory. Ad-hoc `shadow-[…]` values across polished surfaces were enumerated in [tokens-review.md §9](./tokens/tokens-review.md#9-arbitrary-shadow-values-in-production-surfaces), but the list is not exhaustive.
- **Prop extraction for primitives using `React.ComponentProps<"button"> & VariantProps<...>`.** The static parser looks for `*Props` interfaces; inline-type components (most shadcn primitives) get "no Props interface found" in their detail files.
- **Per-component token consumption cross-reference.** Components/detail files don't list which specific tokens each component consumes (would require per-file class-attribute parsing). Token usage counts are global; per-component token drift is flagged only where specific drift was found.
- **Pattern: "detail-page header."** Called out as a sub-pattern inside detail-page doc but not given its own file — instances share only 45 imports, not a complete shape.
---
## Known scope limitations
- **Plugin SDK UI.** In-scope as a contract surface (documented in [components/index.md §Plugin SDK contracts](./components/index.md#plugin-sdk-contracts-11)). Not in-scope for pattern extraction — host implementations are covered; plugin-side usage patterns are not.
- **Low-usage components (12 code imports, 76 of them).** Listed in [components/index.md](./components/index.md) with status marker `📘 below-threshold`; no dedicated detail file. Per the directive: *nothing gets silently dropped*.
- **Pattern documentation:** capped at 10 real patterns. Eleven pattern files exist because the duplicate-family directive required documenting three below-threshold pairs (subscription-panel, sidebar-menu pair inside sidebar-chrome, quota-display). Pattern opportunities surfaced in patterns-review.md are not yet pattern files.
- **UX Lab pages (`InviteUxLab`, `IssueChatUxLab`, `RunTranscriptUxLab`).** Acknowledged prototypes with distinct visual language. Excluded from pattern extraction. Their raw-palette usage is counted in drift stats but not pursued.
- **Hermes / adapter code.** `ui/src/adapters/` contains per-adapter config fields. Not a DS concern; skipped.
- **Mobile treatments.** `MobileBottomNav` and `SwipeToArchive` are noted but not extracted as their own pattern. Mobile patterns appear to live inside individual list/detail pages rather than as shared primitives.
- **Diff mode.** This is a fresh run; `doc/design-system/` did not exist before. No diff was generated. Subsequent re-runs should run in diff mode (see [ds-extraction skill §Diff mode](../../.agents/skills/ds-extraction/SKILL.md#diff-mode)).
---
## What's on disk
```
doc/design-system/
├── REVIEW.md ← you are here
├── _discovery.json ← Stage 0 output
├── _pages.json ← Stage 2 scratch (50 pages)
├── _composition-graph.json ← Stage 2 scratch (135 components)
├── _stories.json ← Stage 2 scratch (14 stories)
├── tokens/
│ ├── tokens.md ← canonical human-readable inventory
│ ├── tokens.json ← machine-readable for downstream tooling
│ └── tokens-review.md ← the high-value drift artifact
├── components/
│ ├── index.md ← all 135 files + 11 SDK contracts, with status markers
│ ├── components-review.md ← duplicates, naming, token non-compliance, story gaps, SDK gap
│ └── [ComponentName].md × 53 ← per-component detail files (3+ uses threshold)
└── patterns/
├── index.md
├── patterns-review.md ← variance, opportunities, what to resolve before re-running
├── list-page.md ← 12 instances
├── detail-page.md ← 8 instances
├── sidebar-chrome.md ← 6 + 2 instances
├── finance-card.md ← 5 instances
├── entity-properties-panel.md ← 4 + 1 instances (open Q on generic)
├── entity-creation-dialog.md ← 4 instances
├── status-display.md ← 3 components + catalog (pending signal tokens)
├── entity-row.md ← 3 instances
├── subscription-panel.md ← 2 instances (below threshold — documented)
└── quota-display.md ← 2 instances (below threshold — documented)
```
Total: **~80 files**.

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"branch": "sockmonster-ds-extraction",
"styling": {
"tailwind_version": "v4",
"tailwind_config": null,
"tailwind_config_note": "No tailwind.config.* file. Tailwind v4 CSS-first config: theme is declared via @theme inline blocks in ui/src/index.css. Build integration via @tailwindcss/vite.",
"css_variables_file": "ui/src/index.css",
"uses_css_variables": true,
"uses_cva": true,
"uses_cn_helper": true,
"cn_helper_location": "ui/src/lib/utils.ts:6",
"shadcn_present": true,
"shadcn_style": "new-york",
"shadcn_base_color": "neutral",
"shadcn_css_variables": true,
"shadcn_rsc": false,
"shadcn_icon_library": "lucide",
"shadcn_aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"utils": "@/lib/utils"
},
"shadcn_skill_path": ".agents/skills/shadcn/SKILL.md",
"other_styling": [
{
"library": "@tailwindcss/typography",
"usage": "@plugin in ui/src/index.css; prose class styling for markdown"
}
],
"notes": "Tailwind v4 with shadcn/ui in new-york style. components.json present. cn() = clsx + tailwind-merge. Custom @custom-variant dark (&:is(.dark *)). No tailwindcss-animate plugin."
},
"tokens": {
"color": {
"sources": ["ui/src/index.css"],
"authoritative_source": "ui/src/index.css",
"definition_blocks": [
{ "block": "@theme inline", "role": "exposes --color-* aliases to Tailwind", "line_range": "6-43" },
{ "block": ":root", "role": "authoritative light-mode values", "line_range": "45-80" },
{ "block": ".dark", "role": "dark-mode overrides", "line_range": "82-115" }
],
"count_estimate": 32,
"categories": {
"semantic_neutral_and_intent": [
"background", "foreground", "card", "card-foreground", "popover", "popover-foreground",
"primary", "primary-foreground", "secondary", "secondary-foreground",
"muted", "muted-foreground", "accent", "accent-foreground",
"destructive", "destructive-foreground",
"border", "input", "ring"
],
"chart": ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"],
"sidebar": [
"sidebar", "sidebar-foreground",
"sidebar-primary", "sidebar-primary-foreground",
"sidebar-accent", "sidebar-accent-foreground",
"sidebar-border", "sidebar-ring"
]
},
"includes_signal_green": false,
"value_format": "oklch",
"dark_mode_convention": ".dark class selector via @custom-variant"
},
"spacing": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"note": "No project-local spacing tokens. Spacing uses Tailwind v4 defaults inherited from tailwindcss package."
},
"type": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"font_faces": [],
"google_fonts": [],
"note": "No project-local font/type tokens. Typography uses Tailwind v4 defaults + @tailwindcss/typography plugin. Markdown styling via `.paperclip-markdown` and `.paperclip-mdxeditor-content` classes with hardcoded font-size/line-height values in index.css."
},
"radius": {
"sources": ["ui/src/index.css"],
"authoritative_source": "ui/src/index.css",
"count_estimate": 5,
"tokens": [
{ "name": "--radius", "value": "0", "defined_at": "ui/src/index.css:47", "scope": ":root" },
{ "name": "--radius-sm", "value": "0.375rem", "defined_at": "ui/src/index.css:39", "scope": "@theme" },
{ "name": "--radius-md", "value": "0.5rem", "defined_at": "ui/src/index.css:40", "scope": "@theme" },
{ "name": "--radius-lg", "value": "0px", "defined_at": "ui/src/index.css:41", "scope": "@theme" },
{ "name": "--radius-xl", "value": "0px", "defined_at": "ui/src/index.css:42", "scope": "@theme" }
],
"note": "Unusual: --radius-lg and --radius-xl are 0px while --radius-sm and --radius-md are non-zero. Likely intentional flat-design choice at the outer scale, but worth confirming. Also --radius (base, :root) = 0 used by MDXEditor integration; not part of @theme."
},
"motion": {
"sources": ["ui/src/index.css"],
"authoritative_source": null,
"count_estimate": 0,
"css_variable_tokens": [],
"keyframes": [
{ "name": "dashboard-activity-enter", "defined_at": "ui/src/index.css:228" },
{ "name": "dashboard-activity-highlight", "defined_at": "ui/src/index.css:246" },
{ "name": "cot-line-slide-in", "defined_at": "ui/src/index.css:272" },
{ "name": "cot-line-slide-out", "defined_at": "ui/src/index.css:277" },
{ "name": "shimmer-text-slide", "defined_at": "ui/src/index.css:298" }
],
"tailwindcss_animate_plugin": false,
"note": "No --motion-* or --duration-* tokens. Motion is defined as inline @keyframes + inline cubic-bezier values (commonly cubic-bezier(0.16, 1, 0.3, 1) and cubic-bezier(0.4, 0, 0.2, 1)). prefers-reduced-motion respected."
},
"elevation": {
"sources": [],
"authoritative_source": null,
"count_estimate": 0,
"note": "No --shadow-* tokens and no theme.boxShadow. Project appears to avoid shadows as a design choice (borders and background shifts carry elevation). Verify during extraction by grepping for box-shadow usage in components."
},
"scoped_non_ds_variables": [
{
"group": "MDXEditor theme bridge",
"selector": ".paperclip-mdxeditor-scope, .paperclip-mdxeditor",
"line_range": "332-361",
"variable_count": 24,
"role": "Maps host DS tokens onto MDXEditor's internal token names (--baseBase, --accentSolid, etc.). Consumed alias layer, not authoritative DS tokens."
},
{
"group": "Shimmer text effect",
"selector": ".shimmer-text",
"variable_count": 2,
"role": "Component-local (--shimmer-base, --shimmer-highlight)."
}
]
},
"components": {
"primary_root": "ui/src/components/",
"layout": "mixed",
"layout_notes": "Not purely flat and not purely nested. Subdirectories exist for a specific subset; the majority live flat at the top level. Naming convention for primitives is lowercase-kebab (button.tsx, dropdown-menu.tsx); composites/features use PascalCase (AgentConfigForm.tsx).",
"subdirectories": [
{ "path": "ui/src/components/ui/", "role": "shadcn primitives", "file_count": 22 },
{ "path": "ui/src/components/access/", "role": "access-control feature cluster", "file_count": 3 },
{ "path": "ui/src/components/transcript/", "role": "run transcript feature cluster", "file_count": 2 }
],
"top_level_tsx_count": 108,
"primitives_in_components_ui": [
"avatar", "badge", "breadcrumb", "button", "card", "checkbox", "collapsible",
"command", "dialog", "dropdown-menu", "input", "label", "popover",
"scroll-area", "select", "separator", "sheet", "skeleton", "tabs",
"textarea", "toggle-switch", "tooltip"
],
"count_estimate": 133,
"plugin_sdk_components": {
"path": "packages/plugins/sdk/src/ui/components.ts",
"status": "ambient-types-only",
"count": 11,
"declared_components": [
"MetricCard", "StatusBadge", "DataTable", "TimeseriesChart", "MarkdownBlock",
"KeyValueList", "ActionBar", "LogView", "JsonTree", "Spinner", "ErrorBoundary"
],
"runtime_model": "Host provides implementations via renderSdkUiComponent(name, props) runtime injection. Plugin bundles ship type declarations only.",
"name_collision_check": "MetricCard.tsx and StatusBadge.tsx exist at ui/src/components/ top-level. The other 9 declared components have no obvious matching file — may exist under different names (e.g., MarkdownBody ≈ MarkdownBlock, JsonSchemaForm unrelated) or may not be implemented yet."
}
},
"usage_surfaces": {
"pages_root": "ui/src/pages/",
"page_count_estimate": 50,
"page_count_method": "find ui/src/pages -name '*.tsx' -not -name '*.test.tsx'",
"other_surfaces": [
{ "path": "ui/src/App.tsx", "role": "root router" },
{ "path": "ui/src/plugins/", "role": "plugin slot & launcher rendering (slots.tsx, launchers.tsx)" }
],
"notable_pages": {
"design_guide": "ui/src/pages/DesignGuide.tsx — an existing in-app design reference page. Imports shadcn primitives; worth cross-referencing during Stage 3 for intended-vs-actual primitive usage. Its presence means a partial DS narrative already exists in-code.",
"ux_labs": [
"ui/src/pages/InviteUxLab.tsx",
"ui/src/pages/IssueChatUxLab.tsx",
"ui/src/pages/RunTranscriptUxLab.tsx"
]
}
},
"storybook": {
"present": true,
"version": "10.3.5",
"config_path": "ui/storybook/.storybook/main.ts",
"stories_location": "centralized",
"stories_glob": "ui/storybook/stories/**/*.stories.@(ts|tsx|mdx)",
"story_file_count": 14,
"story_organization": "thematic",
"story_organization_note": "Stories are organized by domain/theme (foundations, navigation-layout, dialogs-modals, chat-comments, forms-editors, status-language, data-viz-misc, agent-management, issue-management, projects-goals-workspaces, budget-finance, control-plane-surfaces, ux-labs, overview) — NOT one-story-per-component. A single .stories.tsx file typically imports and composes many components. 'Component covered by story' must be computed by parsing import graphs of story files, not by file naming.",
"story_files": [
"foundations.stories.tsx",
"overview.stories.tsx",
"status-language.stories.tsx",
"navigation-layout.stories.tsx",
"dialogs-modals.stories.tsx",
"forms-editors.stories.tsx",
"chat-comments.stories.tsx",
"data-viz-misc.stories.tsx",
"agent-management.stories.tsx",
"issue-management.stories.tsx",
"projects-goals-workspaces.stories.tsx",
"budget-finance.stories.tsx",
"control-plane-surfaces.stories.tsx",
"ux-labs.stories.tsx"
],
"addons": ["@storybook/addon-docs", "@storybook/addon-a11y"],
"covered_components": null,
"covered_components_note": "Deferred to Stage 2 (parse story imports). Discovery only confirms stories exist, not per-component coverage."
},
"existing_docs": {
"design_system_dir_present": false,
"locations": [
{ "path": "ui/README.md", "role": "package readme; non-DS" },
{ "path": "ui/src/pages/DesignGuide.tsx", "role": "in-app design reference page (component-level showcase)" }
],
"figma_sync_config": null,
"style_dictionary_config": null,
"tokens_studio_config": null
},
"known_gaps": [
"Plugin SDK UI is a types-only ambient bridge (packages/plugins/sdk/src/ui/components.ts). The host-provided component kit promised by the SDK is partial: only MetricCard and StatusBadge have matching host implementations by name. 9 other declared components (DataTable, TimeseriesChart, MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary) have no obvious host implementation. PLUGIN_SPEC.md:30 confirms: 'The current runtime does not yet ship a real host-provided plugin UI component kit'.",
"No dedicated spacing, type, or elevation tokens. Those categories rely on Tailwind v4 defaults. Extraction should not synthesize repo-specific tokens where none exist.",
"No central motion token language. Motion is expressed as per-feature @keyframes with inline easing. Treat motion as a candidate for future tokenization rather than documenting a current system.",
"No Figma/style-dictionary/tokens-studio integration. The design system is code-authored, not design-tool-synced."
],
"uncertainties": [
"Storybook organization is thematic (14 composite stories), not per-component. The extraction skill's 'covered_by_story' signal needs to be computed by parsing each story file's import graph and surfacing components used inside render bodies. Flag before Stage 2 so the skill doesn't default to file-name matching.",
"Radius scale is non-monotonic: --radius-sm = 0.375rem, --radius-md = 0.5rem, --radius-lg = 0px, --radius-xl = 0px. Flat-design choice or stale values? Also --radius (base) = 0 at :root coexists with the @theme tokens; which is canonical for Tailwind rounded utilities? Worth confirming before Stage 1 drift analysis flags every rounded-lg usage.",
"Plugin SDK UI contracts 11 shared components but only 2 appear to be implemented by that name in ui/. For extraction scope, should we (a) treat the SDK declarations as DS contract and flag missing implementations as gaps, or (b) ignore the SDK and document only what exists in ui/? Recommendation: (a) — it's higher-value signal for the human reviewer.",
"ui/src/components/ has a mixed layout: 22 shadcn primitives in a 'ui/' subdirectory and 108 components flat at the top level. The top-level mix contains features, composites, one-off pages pieces, and true reusable patterns. Stage 3 will need a heuristic (composition-graph-based) rather than directory-based category inference.",
"Total component count (~133) is meaningfully larger than the skill's illustrative example (87). With the 3+-usage threshold for detail files, output should stay tractable, but the skill should confirm the threshold is right at this scale before Stage 3 starts generating per-component .md files.",
"MDXEditor CSS variable bridge (.paperclip-mdxeditor-scope, 24 --base*/--accent* variables) is DS-adjacent — it consumes host tokens and maps them to MDXEditor internals. Should Stage 1 include these in tokens.json? Recommendation: no — they are consumed aliases, not authoritative tokens. Flag them in tokens-review.md as 'integration layer' rather than as drift."
]
}

View File

@@ -1,646 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"page_count": 50,
"method": "Import-graph extraction: 'from \"@/components/<name>\"' and relative imports. Top-level JSX tree not parsed \u2014 import set is the proxy for rendered-components set. Components a page imports but never renders would inflate the count slightly; low risk here given app style.",
"pages": {
"Activity": {
"path": "ui/src/pages/Activity.tsx",
"components_imported": [
"ActivityRow",
"EmptyState",
"PageSkeleton",
"select"
],
"component_count": 4
},
"AdapterManager": {
"path": "ui/src/pages/AdapterManager.tsx",
"components_imported": [
"PathInstructionsModal",
"badge",
"button",
"card",
"dialog",
"input",
"label"
],
"component_count": 7
},
"AgentDetail": {
"path": "ui/src/pages/AgentDetail.tsx",
"components_imported": [
"ActivityCharts",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"BudgetPolicyCard",
"CopyText",
"EntityRow",
"Identity",
"MarkdownBody",
"MarkdownEditor",
"PackageFileTree",
"PageSkeleton",
"PageTabBar",
"RunTranscriptView",
"ScrollToBottom",
"StatusBadge",
"agent-config-primitives",
"button",
"collapsible",
"input",
"popover",
"skeleton",
"tabs",
"toggle-switch",
"tooltip"
],
"component_count": 25
},
"Agents": {
"path": "ui/src/pages/Agents.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"PageTabBar",
"StatusBadge",
"button",
"tabs"
],
"component_count": 7
},
"ApprovalDetail": {
"path": "ui/src/pages/ApprovalDetail.tsx",
"components_imported": [
"ApprovalPayload",
"Identity",
"MarkdownBody",
"PageSkeleton",
"StatusBadge",
"button",
"textarea"
],
"component_count": 7
},
"Approvals": {
"path": "ui/src/pages/Approvals.tsx",
"components_imported": [
"ApprovalCard",
"PageSkeleton",
"PageTabBar",
"tabs"
],
"component_count": 4
},
"Auth": {
"path": "ui/src/pages/Auth.tsx",
"components_imported": [
"AsciiArtAnimation",
"button"
],
"component_count": 2
},
"BoardClaim": {
"path": "ui/src/pages/BoardClaim.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"CliAuth": {
"path": "ui/src/pages/CliAuth.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"Companies": {
"path": "ui/src/pages/Companies.tsx",
"components_imported": [
"button",
"dropdown-menu",
"input"
],
"component_count": 3
},
"CompanyAccess": {
"path": "ui/src/pages/CompanyAccess.tsx",
"components_imported": [
"badge",
"button",
"checkbox",
"dialog"
],
"component_count": 4
},
"CompanyExport": {
"path": "ui/src/pages/CompanyExport.tsx",
"components_imported": [
"EmptyState",
"MarkdownBody",
"PackageFileTree",
"PageSkeleton",
"button"
],
"component_count": 5
},
"CompanyImport": {
"path": "ui/src/pages/CompanyImport.tsx",
"components_imported": [
"AgentConfigForm",
"EmptyState",
"MarkdownBody",
"PackageFileTree",
"agent-config-defaults",
"agent-config-primitives",
"button"
],
"component_count": 7
},
"CompanyInvites": {
"path": "ui/src/pages/CompanyInvites.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"CompanySettings": {
"path": "ui/src/pages/CompanySettings.tsx",
"components_imported": [
"CompanyPatternIcon",
"agent-config-primitives",
"button"
],
"component_count": 3
},
"CompanySkills": {
"path": "ui/src/pages/CompanySkills.tsx",
"components_imported": [
"EmptyState",
"MarkdownBody",
"MarkdownEditor",
"PageSkeleton",
"button",
"dialog",
"input",
"textarea",
"tooltip"
],
"component_count": 9
},
"Costs": {
"path": "ui/src/pages/Costs.tsx",
"components_imported": [
"BillerSpendCard",
"BudgetIncidentCard",
"BudgetPolicyCard",
"EmptyState",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"Identity",
"PageSkeleton",
"PageTabBar",
"ProviderQuotaCard",
"StatusBadge",
"button",
"card",
"tabs"
],
"component_count": 15
},
"Dashboard": {
"path": "ui/src/pages/Dashboard.tsx",
"components_imported": [
"ActiveAgentsPanel",
"ActivityCharts",
"ActivityRow",
"EmptyState",
"Identity",
"MetricCard",
"PageSkeleton",
"StatusIcon"
],
"component_count": 8
},
"DesignGuide": {
"path": "ui/src/pages/DesignGuide.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"FilterBar",
"Identity",
"InlineEditor",
"IssueReferencePill",
"MetricCard",
"PageSkeleton",
"PriorityIcon",
"StatusBadge",
"StatusIcon",
"avatar",
"badge",
"breadcrumb",
"button",
"card",
"checkbox",
"collapsible",
"command",
"dialog",
"dropdown-menu",
"input",
"label",
"popover",
"scroll-area",
"select",
"separator",
"sheet",
"skeleton",
"tabs",
"textarea",
"tooltip"
],
"component_count": 32
},
"ExecutionWorkspaceDetail": {
"path": "ui/src/pages/ExecutionWorkspaceDetail.tsx",
"components_imported": [
"CopyText",
"ExecutionWorkspaceCloseDialog",
"IssuesList",
"PageTabBar",
"WorkspaceRuntimeControls",
"button",
"card",
"input",
"separator",
"tabs",
"textarea"
],
"component_count": 11
},
"GoalDetail": {
"path": "ui/src/pages/GoalDetail.tsx",
"components_imported": [
"EntityRow",
"GoalProperties",
"GoalTree",
"InlineEditor",
"PageSkeleton",
"StatusBadge",
"button",
"tabs"
],
"component_count": 8
},
"Goals": {
"path": "ui/src/pages/Goals.tsx",
"components_imported": [
"EmptyState",
"GoalTree",
"PageSkeleton",
"button"
],
"component_count": 4
},
"Inbox": {
"path": "ui/src/pages/Inbox.tsx",
"components_imported": [
"ApprovalPayload",
"EmptyState",
"IssueColumns",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueRow",
"PageSkeleton",
"PageTabBar",
"StatusBadge",
"StatusIcon",
"SwipeToArchive",
"button",
"dialog",
"input",
"popover",
"select",
"separator",
"tabs"
],
"component_count": 18
},
"InstanceAccess": {
"path": "ui/src/pages/InstanceAccess.tsx",
"components_imported": [
"button",
"checkbox"
],
"component_count": 2
},
"InstanceExperimentalSettings": {
"path": "ui/src/pages/InstanceExperimentalSettings.tsx",
"components_imported": [
"toggle-switch"
],
"component_count": 1
},
"InstanceGeneralSettings": {
"path": "ui/src/pages/InstanceGeneralSettings.tsx",
"components_imported": [
"ModeBadge",
"button",
"toggle-switch"
],
"component_count": 3
},
"InstanceSettings": {
"path": "ui/src/pages/InstanceSettings.tsx",
"components_imported": [
"EmptyState",
"badge",
"button",
"card"
],
"component_count": 4
},
"InviteLanding": {
"path": "ui/src/pages/InviteLanding.tsx",
"components_imported": [
"CompanyPatternIcon",
"button"
],
"component_count": 2
},
"InviteUxLab": {
"path": "ui/src/pages/InviteUxLab.tsx",
"components_imported": [
"CompanyPatternIcon",
"badge",
"button",
"card"
],
"component_count": 4
},
"IssueChatUxLab": {
"path": "ui/src/pages/IssueChatUxLab.tsx",
"components_imported": [
"IssueChatThread",
"badge",
"button",
"card"
],
"component_count": 4
},
"IssueDetail": {
"path": "ui/src/pages/IssueDetail.tsx",
"components_imported": [
"ApprovalCard",
"Identity",
"ImageGalleryModal",
"InlineEditor",
"IssueChatThread",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueProperties",
"IssueReferenceActivitySummary",
"IssueRelatedWorkPanel",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"MarkdownEditor",
"PriorityIcon",
"ScrollToBottom",
"StatusIcon",
"button",
"popover",
"scroll-area",
"separator",
"sheet",
"skeleton",
"tabs"
],
"component_count": 24
},
"Issues": {
"path": "ui/src/pages/Issues.tsx",
"components_imported": [
"EmptyState",
"IssuesList"
],
"component_count": 2
},
"JoinRequestQueue": {
"path": "ui/src/pages/JoinRequestQueue.tsx",
"components_imported": [
"badge",
"button"
],
"component_count": 2
},
"MyIssues": {
"path": "ui/src/pages/MyIssues.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"StatusIcon"
],
"component_count": 4
},
"NewAgent": {
"path": "ui/src/pages/NewAgent.tsx",
"components_imported": [
"AgentConfigForm",
"ReportsToPicker",
"agent-config-defaults",
"agent-config-primitives",
"button",
"checkbox",
"popover"
],
"component_count": 7
},
"NotFound": {
"path": "ui/src/pages/NotFound.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"Org": {
"path": "ui/src/pages/Org.tsx",
"components_imported": [
"EmptyState",
"PageSkeleton",
"StatusBadge"
],
"component_count": 3
},
"OrgChart": {
"path": "ui/src/pages/OrgChart.tsx",
"components_imported": [
"AgentIconPicker",
"EmptyState",
"PageSkeleton",
"button"
],
"component_count": 4
},
"PluginManager": {
"path": "ui/src/pages/PluginManager.tsx",
"components_imported": [
"badge",
"button",
"card",
"dialog",
"input",
"label"
],
"component_count": 6
},
"PluginPage": {
"path": "ui/src/pages/PluginPage.tsx",
"components_imported": [
"button"
],
"component_count": 1
},
"PluginSettings": {
"path": "ui/src/pages/PluginSettings.tsx",
"components_imported": [
"JsonSchemaForm",
"PageTabBar",
"badge",
"button",
"card",
"separator",
"tabs"
],
"component_count": 7
},
"ProfileSettings": {
"path": "ui/src/pages/ProfileSettings.tsx",
"components_imported": [
"avatar",
"button",
"input",
"label"
],
"component_count": 4
},
"ProjectDetail": {
"path": "ui/src/pages/ProjectDetail.tsx",
"components_imported": [
"BudgetPolicyCard",
"InlineEditor",
"IssuesList",
"PageSkeleton",
"PageTabBar",
"ProjectProperties",
"ProjectWorkspacesContent",
"StatusBadge",
"button",
"tabs"
],
"component_count": 10
},
"ProjectWorkspaceDetail": {
"path": "ui/src/pages/ProjectWorkspaceDetail.tsx",
"components_imported": [
"PathInstructionsModal",
"WorkspaceRuntimeControls",
"button",
"separator"
],
"component_count": 4
},
"Projects": {
"path": "ui/src/pages/Projects.tsx",
"components_imported": [
"EmptyState",
"EntityRow",
"PageSkeleton",
"StatusBadge",
"button"
],
"component_count": 5
},
"RoutineDetail": {
"path": "ui/src/pages/RoutineDetail.tsx",
"components_imported": [
"AgentActionButtons",
"AgentIconPicker",
"EmptyState",
"InlineEntitySelector",
"LiveRunWidget",
"MarkdownEditor",
"PageSkeleton",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"ScheduleEditor",
"badge",
"button",
"collapsible",
"input",
"label",
"select",
"separator",
"tabs",
"toggle-switch"
],
"component_count": 19
},
"Routines": {
"path": "ui/src/pages/Routines.tsx",
"components_imported": [
"AgentIconPicker",
"EmptyState",
"InlineEntitySelector",
"IssuesList",
"MarkdownEditor",
"PageSkeleton",
"PageTabBar",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"button",
"card",
"collapsible",
"dialog",
"dropdown-menu",
"popover",
"select",
"tabs",
"toggle-switch"
],
"component_count": 18
},
"RunTranscriptUxLab": {
"path": "ui/src/pages/RunTranscriptUxLab.tsx",
"components_imported": [
"Identity",
"RunTranscriptView",
"StatusBadge",
"badge",
"button"
],
"component_count": 5
},
"UserProfile": {
"path": "ui/src/pages/UserProfile.tsx",
"components_imported": [
"EmptyState",
"PageSkeleton",
"StatusBadge",
"avatar"
],
"component_count": 4
},
"Workspaces": {
"path": "ui/src/pages/Workspaces.tsx",
"components_imported": [
"PageSkeleton",
"ProjectWorkspacesContent"
],
"component_count": 2
}
}
}

View File

@@ -1,338 +0,0 @@
{
"generated_at": "2026-04-21T00:00:00Z",
"repo_sha": "a26e1288b627e82c554445732c7d844648e6b5e1",
"story_files": {
"agent-management.stories.tsx": {
"path": "ui/storybook/stories/agent-management.stories.tsx",
"components_imported": [
"ActiveAgentsPanel",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"AgentProperties",
"agent-config-defaults",
"agent-config-primitives",
"badge",
"button",
"card",
"select",
"separator"
],
"component_count": 12
},
"budget-finance.stories.tsx": {
"path": "ui/storybook/stories/budget-finance.stories.tsx",
"components_imported": [
"AccountingModelCard",
"BillerSpendCard",
"BudgetIncidentCard",
"BudgetSidebarMarker",
"ClaudeSubscriptionPanel",
"CodexSubscriptionPanel",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"ProviderQuotaCard",
"badge",
"card"
],
"component_count": 12
},
"chat-comments.stories.tsx": {
"path": "ui/storybook/stories/chat-comments.stories.tsx",
"components_imported": [
"CommentThread",
"InlineEntitySelector",
"IssueChatThread",
"MarkdownEditor",
"RunChatSurface",
"badge",
"card"
],
"component_count": 7
},
"control-plane-surfaces.stories.tsx": {
"path": "ui/storybook/stories/control-plane-surfaces.stories.tsx",
"components_imported": [
"ActivityRow",
"ApprovalCard",
"BudgetPolicyCard",
"Identity",
"IssueRow",
"PriorityIcon",
"StatusBadge",
"badge",
"card"
],
"component_count": 9
},
"data-viz-misc.stories.tsx": {
"path": "ui/storybook/stories/data-viz-misc.stories.tsx",
"components_imported": [
"ActivityCharts",
"AsciiArtAnimation",
"CompanyPatternIcon",
"EntityRow",
"FilterBar",
"KanbanBoard",
"LiveRunWidget",
"OnboardingWizard",
"PackageFileTree",
"PageSkeleton",
"StatusBadge",
"SwipeToArchive",
"badge",
"button",
"card"
],
"component_count": 15
},
"dialogs-modals.stories.tsx": {
"path": "ui/storybook/stories/dialogs-modals.stories.tsx",
"components_imported": [
"DocumentDiffModal",
"ExecutionWorkspaceCloseDialog",
"ImageGalleryModal",
"NewAgentDialog",
"NewGoalDialog",
"NewIssueDialog",
"NewProjectDialog",
"PathInstructionsModal",
"badge"
],
"component_count": 9
},
"forms-editors.stories.tsx": {
"path": "ui/storybook/stories/forms-editors.stories.tsx",
"components_imported": [
"EnvVarEditor",
"ExecutionParticipantPicker",
"InlineEditor",
"InlineEntitySelector",
"JsonSchemaForm",
"MarkdownBody",
"MarkdownEditor",
"ReportsToPicker",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"ScheduleEditor",
"badge",
"button"
],
"component_count": 13
},
"foundations.stories.tsx": {
"path": "ui/storybook/stories/foundations.stories.tsx",
"components_imported": [
"badge",
"button",
"card",
"checkbox",
"dialog",
"input",
"label",
"popover",
"select",
"separator",
"tabs",
"textarea",
"toggle-switch",
"tooltip"
],
"component_count": 14
},
"issue-management.stories.tsx": {
"path": "ui/storybook/stories/issue-management.stories.tsx",
"components_imported": [
"Identity",
"IssueColumns",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueLinkQuicklook",
"IssueProperties",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"IssuesQuicklook",
"PriorityIcon",
"StatusBadge",
"badge",
"button",
"card"
],
"component_count": 17
},
"navigation-layout.stories.tsx": {
"path": "ui/storybook/stories/navigation-layout.stories.tsx",
"components_imported": [
"BreadcrumbBar",
"CommandPalette",
"CompanyRail",
"CompanySwitcher",
"KeyboardShortcutsCheatsheet",
"MobileBottomNav",
"PageTabBar",
"Sidebar",
"SidebarAccountMenu",
"SidebarCompanyMenu",
"StatusBadge",
"badge",
"command",
"tabs"
],
"component_count": 14
},
"overview.stories.tsx": {
"path": "ui/storybook/stories/overview.stories.tsx",
"components_imported": [
"badge",
"card"
],
"component_count": 2
},
"projects-goals-workspaces.stories.tsx": {
"path": "ui/storybook/stories/projects-goals-workspaces.stories.tsx",
"components_imported": [
"GoalProperties",
"GoalTree",
"ProjectProperties",
"ProjectWorkspaceSummaryCard",
"ProjectWorkspacesContent",
"WorkspaceRuntimeControls",
"WorktreeBanner",
"badge",
"card"
],
"component_count": 9
},
"status-language.stories.tsx": {
"path": "ui/storybook/stories/status-language.stories.tsx",
"components_imported": [
"CopyText",
"EmptyState",
"Identity",
"MetricCard",
"PriorityIcon",
"QuotaBar",
"StatusBadge",
"card"
],
"component_count": 8
},
"ux-labs.stories.tsx": {
"path": "ui/storybook/stories/ux-labs.stories.tsx",
"components_imported": [],
"component_count": 0
}
},
"covered_components_count": 104,
"covered_components": [
"AccountingModelCard",
"ActiveAgentsPanel",
"ActivityCharts",
"ActivityRow",
"AgentActionButtons",
"AgentConfigForm",
"AgentIconPicker",
"AgentProperties",
"ApprovalCard",
"AsciiArtAnimation",
"BillerSpendCard",
"BreadcrumbBar",
"BudgetIncidentCard",
"BudgetPolicyCard",
"BudgetSidebarMarker",
"ClaudeSubscriptionPanel",
"CodexSubscriptionPanel",
"CommandPalette",
"CommentThread",
"CompanyPatternIcon",
"CompanyRail",
"CompanySwitcher",
"CopyText",
"DocumentDiffModal",
"EmptyState",
"EntityRow",
"EnvVarEditor",
"ExecutionParticipantPicker",
"ExecutionWorkspaceCloseDialog",
"FilterBar",
"FinanceBillerCard",
"FinanceKindCard",
"FinanceTimelineCard",
"GoalProperties",
"GoalTree",
"Identity",
"ImageGalleryModal",
"InlineEditor",
"InlineEntitySelector",
"IssueChatThread",
"IssueColumns",
"IssueContinuationHandoff",
"IssueDocumentsSection",
"IssueFiltersPopover",
"IssueGroupHeader",
"IssueLinkQuicklook",
"IssueProperties",
"IssueRow",
"IssueRunLedger",
"IssueWorkspaceCard",
"IssuesList",
"IssuesQuicklook",
"JsonSchemaForm",
"KanbanBoard",
"KeyboardShortcutsCheatsheet",
"LiveRunWidget",
"MarkdownBody",
"MarkdownEditor",
"MetricCard",
"MobileBottomNav",
"NewAgentDialog",
"NewGoalDialog",
"NewIssueDialog",
"NewProjectDialog",
"OnboardingWizard",
"PackageFileTree",
"PageSkeleton",
"PageTabBar",
"PathInstructionsModal",
"PriorityIcon",
"ProjectProperties",
"ProjectWorkspaceSummaryCard",
"ProjectWorkspacesContent",
"ProviderQuotaCard",
"QuotaBar",
"ReportsToPicker",
"RoutineRunVariablesDialog",
"RoutineVariablesEditor",
"RunChatSurface",
"ScheduleEditor",
"Sidebar",
"SidebarAccountMenu",
"SidebarCompanyMenu",
"StatusBadge",
"SwipeToArchive",
"WorkspaceRuntimeControls",
"WorktreeBanner",
"agent-config-defaults",
"agent-config-primitives",
"badge",
"button",
"card",
"checkbox",
"command",
"dialog",
"input",
"label",
"popover",
"select",
"separator",
"tabs",
"textarea",
"toggle-switch",
"tooltip"
],
"covered_components_note": "Computed by parsing 'from \"@/components/...\"' and relative imports across all .stories.tsx files. Coverage is set membership \u2014 a component appears once if any story imports it, regardless of how many variants/states are rendered."
}

View File

@@ -1,21 +0,0 @@
# ActivityCharts
`ui/src/components/ActivityCharts.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 274 lines
- **Sibling exports:** ChartCard, IssueStatusChart, PriorityChart, RunActivityChart, SuccessRateChart
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `Dashboard`

View File

@@ -1,40 +0,0 @@
# AgentConfigForm
`ui/src/components/AgentConfigForm.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 5 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1403 lines
## Props
### `AgentConfigFormProps`
```ts
adapterModels?: AdapterModel[];
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `AgentDetail`, `CompanyImport`, `NewAgent`

View File

@@ -1,38 +0,0 @@
# AgentIconPicker
`ui/src/components/AgentIconPicker.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 13 imports (4 pages, 9 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 81 lines
- **Sibling exports:** AgentIcon
## Props
### `AgentIconProps`
```ts
icon: string | null | undefined;
className?: string;
```
### `AgentIconPickerProps`
```ts
value: string | null | undefined;
onChange: (icon: string) => void;
children: React.ReactNode;
```
## Composes
- **Primitives:** [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `AgentDetail`, `OrgChart`, `RoutineDetail`, `Routines`

View File

@@ -1,24 +0,0 @@
# ApprovalCard
`ui/src/components/ApprovalCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 153 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [badge](./Badge.md), [button](./Button.md)
## Used by
- **Pages:** `Approvals`, `IssueDetail`

View File

@@ -1,21 +0,0 @@
# ApprovalPayload
`ui/src/components/ApprovalPayload.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (2 pages, 2 components)
- **Storybook:** no
- **File size:** 248 lines
- **Sibling exports:** ApprovalPayloadRenderer, BoardApprovalPayload, BudgetOverridePayload, CeoStrategyPayload, HireAgentPayload
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `ApprovalDetail`, `Inbox`

View File

@@ -1,22 +0,0 @@
# Avatar
`ui/src/components/ui/avatar.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 7 imports (3 pages, 4 components)
- **Storybook:** no
- **File size:** 108 lines
- **Sibling exports:** AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `ProfileSettings`, `UserProfile`
- **Components:** `CommentThread`, `Identity`, `IssueChatThread`, `SidebarAccountMenu`

View File

@@ -1,26 +0,0 @@
# Badge
`ui/src/components/ui/badge.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 18 imports (11 pages, 7 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 49 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `secondary`, `destructive`, `outline`, `ghost`, `link`
## Used by
- **Pages:** `AdapterManager`, `CompanyAccess`, `DesignGuide`, `InstanceSettings`, `InviteUxLab` … (+6 more)
- **Components:** `ApprovalCard`, `BudgetIncidentCard`, `FilterBar`, `FinanceTimelineCard`, `IssueFiltersPopover` … (+2 more)

View File

@@ -1,24 +0,0 @@
# BudgetPolicyCard
`ui/src/components/BudgetPolicyCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 220 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md), [card](./Card.md), [input](./Input.md)
## Used by
- **Pages:** `AgentDetail`, `Costs`, `ProjectDetail`

View File

@@ -1,27 +0,0 @@
# Button
`ui/src/components/ui/button.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 81 imports (41 pages, 38 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 71 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `destructive`, `outline`, `secondary`, `ghost`, `link`
- **size**: `default`, `xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`
## Used by
- **Pages:** `AdapterManager`, `AgentDetail`, `Agents`, `ApprovalDetail`, `Auth` … (+36 more)
- **Components:** `AgentActionButtons`, `AgentConfigForm`, `ApprovalCard`, `BreadcrumbBar`, `BudgetIncidentCard` … (+33 more)

View File

@@ -1,22 +0,0 @@
# Card
`ui/src/components/ui/card.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 18 imports (10 pages, 8 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 93 lines
- **Sibling exports:** CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `Costs`, `DesignGuide`, `ExecutionWorkspaceDetail`, `InstanceSettings` … (+5 more)
- **Components:** `AccountingModelCard`, `BillerSpendCard`, `BudgetIncidentCard`, `BudgetPolicyCard`, `FinanceBillerCard` … (+3 more)

View File

@@ -1,21 +0,0 @@
# Checkbox
`ui/src/components/ui/checkbox.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 6 imports (4 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 33 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `CompanyAccess`, `DesignGuide`, `InstanceAccess`, `NewAgent`
- **Components:** `IssueFiltersPopover`, `JsonSchemaForm`

View File

@@ -1,22 +0,0 @@
# Collapsible
`ui/src/components/ui/collapsible.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (4 pages, 4 components)
- **Storybook:** no
- **File size:** 34 lines
- **Sibling exports:** CollapsibleContent, CollapsibleTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `RoutineDetail`, `Routines`
- **Components:** `IssuesList`, `RoutineVariablesEditor`, `SidebarAgents`, `SidebarProjects`

View File

@@ -1,28 +0,0 @@
# CompanyPatternIcon
`ui/src/components/CompanyPatternIcon.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (3 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 218 lines
## Props
### `CompanyPatternIconProps`
```ts
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
logoFit?: "cover" | "contain";
```
## Used by
- **Pages:** `CompanySettings`, `InviteLanding`, `InviteUxLab`

View File

@@ -1,32 +0,0 @@
# CopyText
`ui/src/components/CopyText.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 88 lines
## Props
### `CopyTextProps`
```ts
text: string;
/** What to display. Defaults to `text`. */
children?: React.ReactNode;
containerClassName?: string;
className?: string;
ariaLabel?: string;
title?: string;
/** Tooltip message shown after copying. Default: "Copied!" */
copiedLabel?: string;
```
## Used by
- **Pages:** `AgentDetail`, `ExecutionWorkspaceDetail`

View File

@@ -1,26 +0,0 @@
# Dialog
`ui/src/components/ui/dialog.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 21 imports (7 pages, 14 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 157 lines
- **Sibling exports:** DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md)
## Used by
- **Pages:** `AdapterManager`, `CompanyAccess`, `CompanySkills`, `DesignGuide`, `Inbox` … (+2 more)
- **Components:** `DocumentDiffModal`, `IssueChatThread`, `KeyboardShortcutsCheatsheet`, `NewAgentDialog`, `NewGoalDialog` … (+9 more)

View File

@@ -1,22 +0,0 @@
# DropdownMenu
`ui/src/components/ui/dropdown-menu.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (3 pages, 5 components)
- **Storybook:** no
- **File size:** 258 lines
- **Sibling exports:** DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `Companies`, `DesignGuide`, `Routines`
- **Components:** `CompanySwitcher`, `IssueChatThread`, `IssueColumns`, `IssueDocumentsSection`, `SidebarCompanyMenu`

View File

@@ -1,31 +0,0 @@
# EmptyState
`ui/src/components/EmptyState.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 20 imports (19 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 28 lines
## Props
### `EmptyStateProps`
```ts
icon: LucideIcon;
message: string;
action?: string;
onAction?: () => void;
```
## Composes
- **Primitives:** [button](./Button.md)
## Used by
- **Pages:** `Activity`, `Agents`, `CompanyExport`, `CompanyImport`, `CompanySkills` … (+14 more)

View File

@@ -1,32 +0,0 @@
# EntityRow
`ui/src/components/EntityRow.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 6 imports (6 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 70 lines
## Props
### `EntityRowProps`
```ts
leading?: ReactNode;
identifier?: string;
title: string;
subtitle?: string;
trailing?: ReactNode;
selected?: boolean;
to?: string;
onClick?: () => void;
className?: string;
```
## Used by
- **Pages:** `AgentDetail`, `Agents`, `DesignGuide`, `GoalDetail`, `MyIssues` … (+1 more)

View File

@@ -1,32 +0,0 @@
# Identity
`ui/src/components/Identity.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 19 imports (7 pages, 12 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 40 lines
## Props
### `IdentityProps`
```ts
name: string;
avatarUrl?: string | null;
initials?: string;
size?: IdentitySize;
className?: string;
```
## Composes
- **Primitives:** [avatar](./Avatar.md)
## Used by
- **Pages:** `AgentDetail`, `ApprovalDetail`, `Costs`, `Dashboard`, `DesignGuide` … (+2 more)

View File

@@ -1,34 +0,0 @@
# InlineEditor
`ui/src/components/InlineEditor.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 6 imports (4 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 310 lines
## Props
### `InlineEditorProps`
```ts
value: string;
onSave: (value: string) => void | Promise<unknown>;
as?: "h1" | "h2" | "p" | "span";
className?: string;
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
nullable?: boolean;
```
## Used by
- **Pages:** `DesignGuide`, `GoalDetail`, `IssueDetail`, `ProjectDetail`

View File

@@ -1,43 +0,0 @@
# InlineEntitySelector
`ui/src/components/InlineEntitySelector.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 8 imports (2 pages, 4 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 215 lines
## Props
### `InlineEntitySelectorProps`
```ts
value: string;
options: InlineEntityOption[];
placeholder: string;
noneLabel: string;
searchPlaceholder: string;
emptyMessage: string;
onChange: (id: string) => void;
onConfirm?: () => void;
className?: string;
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
recentOptionIds?: string[];
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
disablePortal?: boolean;
/** Open the popover when the trigger receives keyboard/programmatic focus. */
openOnFocus?: boolean;
```
## Composes
- **Primitives:** [popover](./Popover.md)
## Used by
- **Pages:** `RoutineDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# Input
`ui/src/components/ui/input.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 20 imports (10 pages, 10 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 22 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `AgentDetail`, `Companies`, `CompanySkills`, `DesignGuide` … (+5 more)
- **Components:** `AgentIconPicker`, `BudgetIncidentCard`, `BudgetPolicyCard`, `IssueDocumentsSection`, `IssueFiltersPopover` … (+5 more)

View File

@@ -1,73 +0,0 @@
# IssueChatThread
`ui/src/components/IssueChatThread.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 4 imports (2 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 2399 lines
## Props
### `IssueChatComposerProps`
```ts
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
```
### `IssueChatThreadProps`
```ts
comments: IssueChatComment[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: IssueChatLinkedRun[];
timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string
```
### `IssueChatErrorBoundaryProps`
```ts
resetKey: string;
messages: readonly ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
children: ReactNode;
```
## Composes
- **Primitives:** [avatar](./Avatar.md), [button](./Button.md), [dialog](./Dialog.md), `dropdown-menu`, [popover](./Popover.md), [textarea](./Textarea.md), [tooltip](./Tooltip.md)
## Used by
- **Pages:** `IssueChatUxLab`, `IssueDetail`

View File

@@ -1,24 +0,0 @@
# IssueFiltersPopover
`ui/src/components/IssueFiltersPopover.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 366 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [badge](./Badge.md), [button](./Button.md), [checkbox](./Checkbox.md), [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `Inbox`

View File

@@ -1,25 +0,0 @@
# IssueLinkQuicklook
`ui/src/components/IssueLinkQuicklook.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (0 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 182 lines
- **Sibling exports:** IssueQuicklookCard
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [popover](./Popover.md)
- **Composites:** [StatusIcon](./StatusIcon.md)
## Used by

View File

@@ -1,20 +0,0 @@
# IssueReferencePill
`ui/src/components/IssueReferencePill.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 4 imports (1 pages, 3 components)
- **Storybook:** no
- **File size:** 56 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`

View File

@@ -1,38 +0,0 @@
# IssueRow
`ui/src/components/IssueRow.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 169 lines
## Props
### `IssueRowProps`
```ts
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
desktopTrailing?: ReactNode;
trailingMeta?: ReactNode;
titleSuffix?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
```
## Used by
- **Pages:** `Inbox`

View File

@@ -1,41 +0,0 @@
# IssueWorkspaceCard
`ui/src/components/IssueWorkspaceCard.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (1 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 522 lines
## Props
### `IssueWorkspaceCardProps`
```ts
issue: Omit<
Pick<
Issue,
| "companyId"
| "projectId"
| "projectWorkspaceId"
| "executionWorkspaceId"
| "executionWorkspacePreference"
| "executionWorkspaceSettings"
>,
"companyId"
> & {
companyId: string | null;
currentExecutionWorkspace?: ExecutionWorkspace | null;
```
## Composes
- **Primitives:** [button](./Button.md), [skeleton](./Skeleton.md)
## Used by
- **Pages:** `IssueDetail`

View File

@@ -1,45 +0,0 @@
# IssuesList
`ui/src/components/IssuesList.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 6 imports (5 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1170 lines
## Props
### `IssuesListProps`
```ts
issues: Issue[];
isLoading?: boolean;
error?: Error | null;
agents?: Agent[];
projects?: ProjectOption[];
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialWorkspaces?: string[];
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
baseCreateIssueDefaults?: Record<string, unknown>;
createIssueLabel?: string;
enableRoutineVisibilityFilter?: boolean;
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
```
## Composes
- **Primitives:** [button](./Button.md), [collapsible](./Collapsible.md), [input](./Input.md), [popover](./Popover.md)
## Used by
- **Pages:** `ExecutionWorkspaceDetail`, `IssueDetail`, `Issues`, `ProjectDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# Label
`ui/src/components/ui/label.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 8 imports (5 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 23 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AdapterManager`, `DesignGuide`, `PluginManager`, `ProfileSettings`, `RoutineDetail`
- **Components:** `JsonSchemaForm`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`

View File

@@ -1,32 +0,0 @@
# MarkdownBody
`ui/src/components/MarkdownBody.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 11 imports (5 pages, 6 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 326 lines
## Props
### `MarkdownBodyProps`
```ts
children: string;
className?: string;
style?: React.CSSProperties;
softBreaks?: boolean;
linkIssueReferences?: boolean;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
```
## Used by
- **Pages:** `AgentDetail`, `ApprovalDetail`, `CompanyExport`, `CompanyImport`, `CompanySkills`

View File

@@ -1,39 +0,0 @@
# MarkdownEditor
`ui/src/components/MarkdownEditor.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 16 imports (5 pages, 9 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 1204 lines
## Props
### `MarkdownEditorProps`
```ts
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
onDropFile?: (file: File) => Promise<void>;
bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[];
/** Called on Cmd/Ctrl+Enter */
onSubmit?: () => void;
/** Render the rich editor without allowing edits. */
readOnly?: boolean;
```
## Used by
- **Pages:** `AgentDetail`, `CompanySkills`, `IssueDetail`, `RoutineDetail`, `Routines`

View File

@@ -1,21 +0,0 @@
# PackageFileTree
`ui/src/components/PackageFileTree.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (3 pages, 0 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 327 lines
- **Sibling exports:** FRONTMATTER_FIELD_LABELS
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `CompanyExport`, `CompanyImport`

View File

@@ -1,36 +0,0 @@
# PageSkeleton
`ui/src/components/PageSkeleton.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 23 imports (22 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 181 lines
## Props
### `PageSkeletonProps`
```ts
variant?:
| "list"
| "issues-list"
| "detail"
| "dashboard"
| "approvals"
| "costs"
| "inbox"
| "org-chart";
```
## Composes
- **Primitives:** [skeleton](./Skeleton.md)
## Used by
- **Pages:** `Activity`, `AgentDetail`, `Agents`, `ApprovalDetail`, `Approvals` … (+17 more)

View File

@@ -1,32 +0,0 @@
# PageTabBar
`ui/src/components/PageTabBar.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 10 imports (9 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 46 lines
## Props
### `PageTabBarProps`
```ts
items: PageTabItem[];
value?: string;
onValueChange?: (value: string) => void;
align?: "center" | "start";
```
## Composes
- **Primitives:** [tabs](./Tabs.md)
## Used by
- **Pages:** `AgentDetail`, `Agents`, `Approvals`, `Costs`, `ExecutionWorkspaceDetail` … (+4 more)
- **Components:** `CompanySettingsNav`

View File

@@ -1,30 +0,0 @@
# PathInstructionsModal
`ui/src/components/PathInstructionsModal.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 12 imports (2 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 144 lines
- **Sibling exports:** ChoosePathButton
## Props
### `PathInstructionsModalProps`
```ts
open: boolean;
onOpenChange: (open: boolean) => void;
```
## Composes
- **Primitives:** [dialog](./Dialog.md)
## Used by
- **Pages:** `AdapterManager`, `ProjectWorkspaceDetail`

View File

@@ -1,22 +0,0 @@
# Popover
`ui/src/components/ui/popover.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 27 imports (6 pages, 20 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 89 lines
- **Sibling exports:** PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `Inbox`, `IssueDetail`, `NewAgent` … (+1 more)
- **Components:** `AgentConfigForm`, `AgentIconPicker`, `ExecutionParticipantPicker`, `GoalProperties`, `InlineEntitySelector` … (+15 more)

View File

@@ -1,31 +0,0 @@
# PriorityIcon
`ui/src/components/PriorityIcon.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 5 imports (2 pages, 3 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 78 lines
## Props
### `PriorityIconProps`
```ts
priority: string;
onChange?: (priority: string) => void;
className?: string;
showLabel?: boolean;
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `DesignGuide`, `IssueDetail`

View File

@@ -1,24 +0,0 @@
# RoutineRunVariablesDialog
`ui/src/components/RoutineRunVariablesDialog.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 519 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Composes
- **Primitives:** [button](./Button.md), [dialog](./Dialog.md), [input](./Input.md), [label](./Label.md), [select](./Select.md), [textarea](./Textarea.md)
## Used by
- **Pages:** `RoutineDetail`, `Routines`

View File

@@ -1,32 +0,0 @@
# RunTranscriptView
`ui/src/components/transcript/RunTranscriptView.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** no
- **File size:** 1446 lines
## Props
### `RunTranscriptViewProps`
```ts
entries: TranscriptEntry[];
mode?: TranscriptMode;
density?: TranscriptDensity;
limit?: number;
streaming?: boolean;
collapseStdout?: boolean;
emptyMessage?: string;
className?: string;
thinkingClassName?: string;
```
## Used by
- **Pages:** `AgentDetail`, `RunTranscriptUxLab`

View File

@@ -1,22 +0,0 @@
# ScrollArea
`ui/src/components/ui/scroll-area.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 3 imports (2 pages, 1 components)
- **Storybook:** no
- **File size:** 57 lines
- **Sibling exports:** ScrollBar
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `IssueDetail`
- **Components:** `PropertiesPanel`

View File

@@ -1,22 +0,0 @@
# Select
`ui/src/components/ui/select.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 10 imports (5 pages, 5 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 189 lines
- **Sibling exports:** SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `Activity`, `DesignGuide`, `Inbox`, `RoutineDetail`, `Routines`
- **Components:** `DocumentDiffModal`, `JsonSchemaForm`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`, `ScheduleEditor`

View File

@@ -1,21 +0,0 @@
# Separator
`ui/src/components/ui/separator.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 11 imports (7 pages, 4 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 29 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `DesignGuide`, `ExecutionWorkspaceDetail`, `Inbox`, `IssueDetail`, `PluginSettings` … (+2 more)
- **Components:** `AgentProperties`, `GoalProperties`, `IssueProperties`, `ProjectProperties`

View File

@@ -1,33 +0,0 @@
# SidebarNavItem
`ui/src/components/SidebarNavItem.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 3 imports (0 pages, 3 components)
- **Storybook:** no
- **File size:** 95 lines
## Props
### `SidebarNavItemProps`
```ts
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
className?: string;
badge?: number;
badgeTone?: "default" | "danger";
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean;
liveCount?: number;
```
## Used by

View File

@@ -1,21 +0,0 @@
# Skeleton
`ui/src/components/ui/skeleton.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 6 imports (3 pages, 3 components)
- **Storybook:** no
- **File size:** 14 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `DesignGuide`, `IssueDetail`
- **Components:** `IssueWorkspaceCard`, `PageSkeleton`, `ProviderQuotaCard`

View File

@@ -1,20 +0,0 @@
# StatusBadge
`ui/src/components/StatusBadge.tsx`
[INFER] Standalone component. No file-level docstring.
## Quick facts
- **Category:** `standalone`
- **Usage:** 19 imports (12 pages, 7 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 16 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `AgentDetail`, `Agents`, `ApprovalDetail`, `Costs`, `DesignGuide` … (+7 more)

View File

@@ -1,32 +0,0 @@
# StatusIcon
`ui/src/components/StatusIcon.tsx`
[INFER] Composite component. No file-level docstring.
## Quick facts
- **Category:** `composite`
- **Usage:** 14 imports (5 pages, 9 components)
- **Storybook:** no
- **File size:** 72 lines
## Props
### `StatusIconProps`
```ts
status: string;
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
```
## Composes
- **Primitives:** [button](./Button.md), [popover](./Popover.md)
## Used by
- **Pages:** `Dashboard`, `DesignGuide`, `Inbox`, `IssueDetail`, `MyIssues`
- **Components:** `IssueLinkQuicklook` … (+8 more)

View File

@@ -1,27 +0,0 @@
# Tabs
`ui/src/components/ui/tabs.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 15 imports (13 pages, 2 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 90 lines
- **Sibling exports:** TabsContent, TabsList, TabsTrigger
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Variants (CVA)
- **variant**: `default`, `line`
## Used by
- **Pages:** `AgentDetail`, `Agents`, `Approvals`, `Costs`, `DesignGuide` … (+8 more)
- **Components:** `CompanySettingsNav`, `PageTabBar`

View File

@@ -1,21 +0,0 @@
# Textarea
`ui/src/components/ui/textarea.tsx`
[INFER] Primitive component. No file-level docstring.
## Quick facts
- **Category:** `primitive`
- **Usage:** 9 imports (4 pages, 5 components)
- **Storybook:** yes — see ui/storybook/stories/ (thematic stories, not per-component)
- **File size:** 19 lines
## Props
[INFER] No `*Props` interface/type found by static extraction. See source file.
## Used by
- **Pages:** `ApprovalDetail`, `CompanySkills`, `DesignGuide`, `ExecutionWorkspaceDetail`
- **Components:** `IssueChatThread`, `JsonSchemaForm`, `OutputFeedbackButtons`, `RoutineRunVariablesDialog`, `RoutineVariablesEditor`

Some files were not shown because too many files have changed in this diff Show More