Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
name: doc-maintenance
|
||||||
|
description: >
|
||||||
|
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
|
||||||
|
history to find drift — shipped features missing from docs or features
|
||||||
|
listed as upcoming that already landed. Proposes minimal edits, creates
|
||||||
|
a branch, and opens a PR. Use when asked to review docs for accuracy,
|
||||||
|
after major feature merges, or on a periodic schedule.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Doc Maintenance Skill
|
||||||
|
|
||||||
|
Detect documentation drift and fix it via PR — no rewrites, no churn.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Periodic doc review (e.g. weekly or after releases)
|
||||||
|
- After major feature merges
|
||||||
|
- When asked "are our docs up to date?"
|
||||||
|
- When asked to audit README / SPEC / PRODUCT accuracy
|
||||||
|
|
||||||
|
## Target Documents
|
||||||
|
|
||||||
|
| Document | Path | What matters |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
|
||||||
|
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
|
||||||
|
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
|
||||||
|
|
||||||
|
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
|
||||||
|
release notes. These are dev-facing or ephemeral — lower risk of user-facing
|
||||||
|
confusion.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1 — Detect what changed
|
||||||
|
|
||||||
|
Find the last review cursor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read the last-reviewed commit SHA
|
||||||
|
CURSOR_FILE=".doc-review-cursor"
|
||||||
|
if [ -f "$CURSOR_FILE" ]; then
|
||||||
|
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
|
||||||
|
else
|
||||||
|
# First run: look back 60 days
|
||||||
|
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then gather commits since the cursor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log "$LAST_SHA"..HEAD --oneline --no-merges
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Classify changes
|
||||||
|
|
||||||
|
Scan commit messages and changed files. Categorize into:
|
||||||
|
|
||||||
|
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
|
||||||
|
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
|
||||||
|
- **Structural** — new directories, config changes, new adapters, new CLI commands
|
||||||
|
|
||||||
|
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
|
||||||
|
changes, style/formatting commits. These don't affect doc accuracy.
|
||||||
|
|
||||||
|
For borderline cases, check the actual diff — a commit titled "refactor: X"
|
||||||
|
that adds a new public API is a feature.
|
||||||
|
|
||||||
|
### Step 3 — Build a change summary
|
||||||
|
|
||||||
|
Produce a concise list like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Since last review (<sha>, <date>):
|
||||||
|
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
|
||||||
|
- FEATURE: Project archiving added
|
||||||
|
- BREAKING: Removed legacy webhook adapter
|
||||||
|
- STRUCTURAL: New .agents/skills/ directory convention
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no notable changes, skip to Step 7 (update cursor and exit).
|
||||||
|
|
||||||
|
### Step 4 — Audit each target doc
|
||||||
|
|
||||||
|
For each target document, read it fully and cross-reference against the change
|
||||||
|
summary. Check for:
|
||||||
|
|
||||||
|
1. **False negatives** — major shipped features not mentioned at all
|
||||||
|
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
|
||||||
|
/ "not supported" / "TBD" that already shipped
|
||||||
|
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
|
||||||
|
still correct (README only)
|
||||||
|
4. **Feature table accuracy** — does the features section reflect current
|
||||||
|
capabilities? (README only)
|
||||||
|
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
|
||||||
|
|
||||||
|
Use `references/audit-checklist.md` as the structured checklist.
|
||||||
|
Use `references/section-map.md` to know where to look for each feature area.
|
||||||
|
|
||||||
|
### Step 5 — Create branch and apply minimal edits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a branch for the doc updates
|
||||||
|
BRANCH="docs/maintenance-$(date +%Y%m%d)"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply **only** the edits needed to fix drift. Rules:
|
||||||
|
|
||||||
|
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
|
||||||
|
- **Preserve voice and style.** Match the existing tone of each document.
|
||||||
|
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
|
||||||
|
sections unless they're part of a factual fix.
|
||||||
|
- **No new sections.** If a feature needs a whole new section, note it in the
|
||||||
|
PR description as a follow-up — don't add it in a maintenance pass.
|
||||||
|
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
|
||||||
|
in the appropriate existing section if there isn't one already. Don't add
|
||||||
|
long descriptions.
|
||||||
|
|
||||||
|
### Step 6 — Open a PR
|
||||||
|
|
||||||
|
Commit the changes and open a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
|
||||||
|
git commit -m "docs: update documentation for accuracy
|
||||||
|
|
||||||
|
- [list each fix briefly]
|
||||||
|
|
||||||
|
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
|
||||||
|
|
||||||
|
git push -u origin "$BRANCH"
|
||||||
|
|
||||||
|
gh pr create \
|
||||||
|
--title "docs: periodic documentation accuracy update" \
|
||||||
|
--body "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
Automated doc maintenance pass. Fixes documentation drift detected since
|
||||||
|
last review.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- [list each fix]
|
||||||
|
|
||||||
|
### Change summary (since last review)
|
||||||
|
- [list notable code changes that triggered doc updates]
|
||||||
|
|
||||||
|
## Review notes
|
||||||
|
- Only factual accuracy fixes — no style/cosmetic changes
|
||||||
|
- Preserves existing voice and structure
|
||||||
|
- Larger doc additions (new sections, tutorials) noted as follow-ups
|
||||||
|
|
||||||
|
🤖 Generated by doc-maintenance skill
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7 — Update the cursor
|
||||||
|
|
||||||
|
After a successful audit (whether or not edits were needed), update the cursor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse HEAD > .doc-review-cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
If edits were made, this is already committed in the PR branch. If no edits
|
||||||
|
were needed, commit the cursor update to the current branch.
|
||||||
|
|
||||||
|
## Change Classification Rules
|
||||||
|
|
||||||
|
| Signal | Category | Doc update needed? |
|
||||||
|
|--------|----------|-------------------|
|
||||||
|
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
|
||||||
|
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
|
||||||
|
| New top-level directory or config file | Structural | Maybe |
|
||||||
|
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
|
||||||
|
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
|
||||||
|
| `docs:` | Doc change | No (already handled) |
|
||||||
|
| Dependency bumps only | Maintenance | No |
|
||||||
|
|
||||||
|
## Patch Style Guide
|
||||||
|
|
||||||
|
- Fix the fact, not the prose
|
||||||
|
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
|
||||||
|
- If adding a feature mention, match the format of surrounding entries
|
||||||
|
(e.g. if features are in a table, add a table row)
|
||||||
|
- Keep README changes especially minimal — it shouldn't churn often
|
||||||
|
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
|
||||||
|
(e.g. change "not supported in V1" to "supported via X" rather than adding
|
||||||
|
a new section)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
When the skill completes, report:
|
||||||
|
|
||||||
|
- How many commits were scanned
|
||||||
|
- How many notable changes were found
|
||||||
|
- How many doc edits were made (and to which files)
|
||||||
|
- PR link (if edits were made)
|
||||||
|
- Any follow-up items that need larger doc work
|
||||||
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Doc Maintenance Audit Checklist
|
||||||
|
|
||||||
|
Use this checklist when auditing each target document. For each item, compare
|
||||||
|
against the change summary from git history.
|
||||||
|
|
||||||
|
## README.md
|
||||||
|
|
||||||
|
### Features table
|
||||||
|
- [ ] Each feature card reflects a shipped capability
|
||||||
|
- [ ] No feature cards for things that don't exist yet
|
||||||
|
- [ ] No major shipped features missing from the table
|
||||||
|
|
||||||
|
### Roadmap
|
||||||
|
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
|
||||||
|
- [ ] No removed/cancelled items still listed
|
||||||
|
- [ ] Items reflect current priorities (cross-check with recent PRs)
|
||||||
|
|
||||||
|
### Quickstart
|
||||||
|
- [ ] `npx paperclipai onboard` command is correct
|
||||||
|
- [ ] Manual install steps are accurate (clone URL, commands)
|
||||||
|
- [ ] Prerequisites (Node version, pnpm version) are current
|
||||||
|
- [ ] Server URL and port are correct
|
||||||
|
|
||||||
|
### "What is Paperclip" section
|
||||||
|
- [ ] High-level description is accurate
|
||||||
|
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
|
||||||
|
|
||||||
|
### "Works with" table
|
||||||
|
- [ ] All supported adapters/runtimes are listed
|
||||||
|
- [ ] No removed adapters still listed
|
||||||
|
- [ ] Logos and labels match current adapter names
|
||||||
|
|
||||||
|
### "Paperclip is right for you if"
|
||||||
|
- [ ] Use cases are still accurate
|
||||||
|
- [ ] No claims about capabilities that don't exist
|
||||||
|
|
||||||
|
### "Why Paperclip is special"
|
||||||
|
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
|
||||||
|
- [ ] No features listed that were removed or significantly changed
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
- [ ] Answers are still correct
|
||||||
|
- [ ] No references to removed features or outdated behavior
|
||||||
|
|
||||||
|
### Development section
|
||||||
|
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
|
||||||
|
- [ ] Link to DEVELOPING.md is correct
|
||||||
|
|
||||||
|
## doc/SPEC.md
|
||||||
|
|
||||||
|
### Company Model
|
||||||
|
- [ ] Fields match current schema
|
||||||
|
- [ ] Governance model description is accurate
|
||||||
|
|
||||||
|
### Agent Model
|
||||||
|
- [ ] Adapter types match what's actually supported
|
||||||
|
- [ ] Agent configuration description is accurate
|
||||||
|
- [ ] No features described as "not supported" or "not V1" that shipped
|
||||||
|
|
||||||
|
### Task Model
|
||||||
|
- [ ] Task hierarchy description is accurate
|
||||||
|
- [ ] Status values match current implementation
|
||||||
|
|
||||||
|
### Extensions / Plugins
|
||||||
|
- [ ] If plugins are shipped, no "not in V1" or "future" language
|
||||||
|
- [ ] Plugin model description matches implementation
|
||||||
|
|
||||||
|
### Open Questions
|
||||||
|
- [ ] Resolved questions removed or updated
|
||||||
|
- [ ] No "TBD" items that have been decided
|
||||||
|
|
||||||
|
## doc/PRODUCT.md
|
||||||
|
|
||||||
|
### Core Concepts
|
||||||
|
- [ ] Company, Employees, Task Management descriptions accurate
|
||||||
|
- [ ] Agent Execution modes described correctly
|
||||||
|
- [ ] No missing major concepts
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- [ ] Principles haven't been contradicted by shipped features
|
||||||
|
- [ ] No principles referencing removed capabilities
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
- [ ] Dream scenario still reflects actual onboarding
|
||||||
|
- [ ] Steps are achievable with current features
|
||||||
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Section Map
|
||||||
|
|
||||||
|
Maps feature areas to specific document sections so the skill knows where to
|
||||||
|
look when a feature ships or changes.
|
||||||
|
|
||||||
|
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|
||||||
|
|-------------|---------------|-------------|----------------|
|
||||||
|
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
|
||||||
|
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
|
||||||
|
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
|
||||||
|
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
|
||||||
|
| Task Management | Features table | Task Model | Task Management |
|
||||||
|
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
|
||||||
|
| Multi-Company | Features table, FAQ | Company Model | Company |
|
||||||
|
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
|
||||||
|
| CLI Commands | Development section | — | — |
|
||||||
|
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
|
||||||
|
| Skills / Skill Injection | "Why special" | — | — |
|
||||||
|
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
|
||||||
|
| Mobile / UI | Features table | — | — |
|
||||||
|
| Project Archiving | — | — | — |
|
||||||
|
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,6 +38,10 @@ tmp/
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.paperclip-local/
|
.paperclip-local/
|
||||||
|
|
||||||
|
# Doc maintenance cursor
|
||||||
|
.doc-review-cursor
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
tests/e2e/test-results/
|
tests/e2e/test-results/
|
||||||
tests/e2e/playwright-report/
|
tests/e2e/playwright-report/
|
||||||
|
.superset/
|
||||||
@@ -239,7 +239,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
|||||||
- ⚪ ClipMart - buy and sell entire agent companies
|
- ⚪ ClipMart - buy and sell entire agent companies
|
||||||
- ⚪ Easy agent configurations / easier to understand
|
- ⚪ Easy agent configurations / easier to understand
|
||||||
- ⚪ Better support for harness engineering
|
- ⚪ Better support for harness engineering
|
||||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||||
- ⚪ Better docs
|
- ⚪ Better docs
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build
|
|||||||
|
|
||||||
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
||||||
|
|
||||||
|
## Docker For Untrusted PR Review
|
||||||
|
|
||||||
|
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||||
|
|
||||||
## Database in Dev (Auto-Handled)
|
## Database in Dev (Auto-Handled)
|
||||||
|
|
||||||
For local development, leave `DATABASE_URL` unset.
|
For local development, leave `DATABASE_URL` unset.
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ Notes:
|
|||||||
- Without API keys, the app still runs normally.
|
- Without API keys, the app still runs normally.
|
||||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||||
|
|
||||||
|
## Untrusted PR Review Container
|
||||||
|
|
||||||
|
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||||
|
|
||||||
|
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
|
||||||
|
|
||||||
## Onboard Smoke Test (Ubuntu + npm only)
|
## Onboard Smoke Test (Ubuntu + npm only)
|
||||||
|
|
||||||
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
||||||
|
|||||||
15
doc/SPEC.md
15
doc/SPEC.md
@@ -188,12 +188,15 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
|||||||
|
|
||||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||||
|
|
||||||
| Adapter | Mechanism | Example |
|
| Adapter | Mechanism | Example |
|
||||||
| --------- | ----------------------- | --------------------------------------------- |
|
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||||
|
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||||
|
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||||
|
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||||
|
|
||||||
The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture).
|
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||||
|
|
||||||
### Adapter Interface
|
### Adapter Interface
|
||||||
|
|
||||||
@@ -429,7 +432,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
|
|||||||
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
||||||
- Plugin-registrable UI components (future)
|
- Plugin-registrable UI components (future)
|
||||||
|
|
||||||
This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible.
|
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Untrusted PR Review In Docker
|
||||||
|
|
||||||
|
Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly.
|
||||||
|
|
||||||
|
This is intentionally separate from the normal Paperclip dev image.
|
||||||
|
|
||||||
|
## What this container isolates
|
||||||
|
|
||||||
|
- `codex` auth/session state in a Docker volume, not your host `~/.codex`
|
||||||
|
- `claude` auth/session state in a Docker volume, not your host `~/.claude`
|
||||||
|
- `gh` auth state in the same container-local home volume
|
||||||
|
- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work`
|
||||||
|
|
||||||
|
By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docker/untrusted-review/Dockerfile`
|
||||||
|
- `docker-compose.untrusted-review.yml`
|
||||||
|
- `review-checkout-pr` inside the container
|
||||||
|
|
||||||
|
## Build and start a shell
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.untrusted-review.yml build
|
||||||
|
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||||
|
```
|
||||||
|
|
||||||
|
That opens an interactive shell in the review container with:
|
||||||
|
|
||||||
|
- Node + Corepack/pnpm
|
||||||
|
- `codex`
|
||||||
|
- `claude`
|
||||||
|
- `gh`
|
||||||
|
- `git`, `rg`, `fd`, `jq`
|
||||||
|
|
||||||
|
## First-time login inside the container
|
||||||
|
|
||||||
|
Run these once. The resulting login state persists in the `review-home` Docker volume.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh auth login
|
||||||
|
codex login
|
||||||
|
claude login
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check out a PR safely
|
||||||
|
|
||||||
|
Inside the container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
review-checkout-pr paperclipai/paperclip 432
|
||||||
|
cd /work/checkouts/paperclipai-paperclip/pr-432
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Creates or reuses a repo clone under `/work/repos/...`
|
||||||
|
2. Fetches `pull/<pr>/head` from GitHub
|
||||||
|
3. Creates a detached git worktree under `/work/checkouts/...`
|
||||||
|
|
||||||
|
The checkout lives entirely inside the container volume.
|
||||||
|
|
||||||
|
## Ask Codex or Claude to review it
|
||||||
|
|
||||||
|
Inside the PR checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
codex
|
||||||
|
```
|
||||||
|
|
||||||
|
Then give it a prompt like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references.
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Claude:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preview the Paperclip app from the PR
|
||||||
|
|
||||||
|
Only do this when you intentionally want to execute the PR's code inside the container.
|
||||||
|
|
||||||
|
Inside the PR checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
HOST=0.0.0.0 pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open from the host:
|
||||||
|
|
||||||
|
- `http://localhost:3100`
|
||||||
|
|
||||||
|
The Compose file also exposes Vite's default port:
|
||||||
|
|
||||||
|
- `http://localhost:5173`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host.
|
||||||
|
- If you only want static inspection, do not run install/dev commands.
|
||||||
|
- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`.
|
||||||
|
|
||||||
|
## Reset state
|
||||||
|
|
||||||
|
Remove the review container volumes when you want a clean environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.untrusted-review.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
That deletes:
|
||||||
|
|
||||||
|
- Codex/Claude/GitHub login state stored in `review-home`
|
||||||
|
- cloned repos, worktrees, installs, and scratch data stored in `review-work`
|
||||||
|
|
||||||
|
## Security limits
|
||||||
|
|
||||||
|
This is a useful isolation boundary, but it is still Docker, not a full VM.
|
||||||
|
|
||||||
|
- A reviewed PR can still access the container's network unless you disable it.
|
||||||
|
- Any secrets you pass into the container are available to code you execute inside it.
|
||||||
|
- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary.
|
||||||
|
- If you need a stronger boundary than this, use a disposable VM instead of Docker.
|
||||||
33
docker-compose.untrusted-review.yml
Normal file
33
docker-compose.untrusted-review.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
review:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/untrusted-review/Dockerfile
|
||||||
|
init: true
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
working_dir: /work
|
||||||
|
environment:
|
||||||
|
HOME: "/home/reviewer"
|
||||||
|
CODEX_HOME: "/home/reviewer/.codex"
|
||||||
|
CLAUDE_HOME: "/home/reviewer/.claude"
|
||||||
|
PAPERCLIP_HOME: "/home/reviewer/.paperclip-review"
|
||||||
|
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||||
|
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||||
|
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
|
||||||
|
ports:
|
||||||
|
- "${REVIEW_PAPERCLIP_PORT:-3100}:3100"
|
||||||
|
- "${REVIEW_VITE_PORT:-5173}:5173"
|
||||||
|
volumes:
|
||||||
|
- review-home:/home/reviewer
|
||||||
|
- review-work:/work
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:mode=1777,size=1g
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
review-home:
|
||||||
|
review-work:
|
||||||
44
docker/untrusted-review/Dockerfile
Normal file
44
docker/untrusted-review/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
FROM node:lts-trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
fd-find \
|
||||||
|
gh \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
less \
|
||||||
|
openssh-client \
|
||||||
|
procps \
|
||||||
|
ripgrep \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||||
|
|
||||||
|
RUN corepack enable \
|
||||||
|
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
|
||||||
|
|
||||||
|
RUN useradd --create-home --shell /bin/bash reviewer
|
||||||
|
|
||||||
|
ENV HOME=/home/reviewer \
|
||||||
|
CODEX_HOME=/home/reviewer/.codex \
|
||||||
|
CLAUDE_HOME=/home/reviewer/.claude \
|
||||||
|
PAPERCLIP_HOME=/home/reviewer/.paperclip-review \
|
||||||
|
PNPM_HOME=/home/reviewer/.local/share/pnpm \
|
||||||
|
PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
|
||||||
|
COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/review-checkout-pr \
|
||||||
|
&& mkdir -p /work \
|
||||||
|
&& chown -R reviewer:reviewer /work
|
||||||
|
|
||||||
|
USER reviewer
|
||||||
|
|
||||||
|
EXPOSE 3100 5173
|
||||||
|
|
||||||
|
CMD ["bash", "-l"]
|
||||||
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: review-checkout-pr <owner/repo|github-url> <pr-number> [checkout-dir]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
review-checkout-pr paperclipai/paperclip 432
|
||||||
|
review-checkout-pr https://github.com/paperclipai/paperclip.git 432
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalize_repo_slug() {
|
||||||
|
local raw="$1"
|
||||||
|
raw="${raw#git@github.com:}"
|
||||||
|
raw="${raw#ssh://git@github.com/}"
|
||||||
|
raw="${raw#https://github.com/}"
|
||||||
|
raw="${raw#http://github.com/}"
|
||||||
|
raw="${raw%.git}"
|
||||||
|
printf '%s\n' "${raw#/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_slug="$(normalize_repo_slug "$1")"
|
||||||
|
pr_number="$2"
|
||||||
|
|
||||||
|
if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then
|
||||||
|
echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "PR number must be numeric, got: $pr_number" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_key="${repo_slug//\//-}"
|
||||||
|
mirror_dir="/work/repos/${repo_key}"
|
||||||
|
checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}"
|
||||||
|
pr_ref="refs/remotes/origin/pr/${pr_number}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")"
|
||||||
|
|
||||||
|
if [[ ! -d "$mirror_dir/.git" ]]; then
|
||||||
|
if command -v gh >/dev/null 2>&1; then
|
||||||
|
gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none
|
||||||
|
else
|
||||||
|
git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}"
|
||||||
|
|
||||||
|
if [[ -e "$checkout_dir" ]]; then
|
||||||
|
printf '%s\n' "$checkout_dir"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null
|
||||||
|
printf '%s\n' "$checkout_dir"
|
||||||
@@ -103,9 +103,10 @@ export interface HostServices {
|
|||||||
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provides `events.emit`. */
|
/** Provides `events.emit` and `events.subscribe`. */
|
||||||
events: {
|
events: {
|
||||||
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||||
|
subscribe(params: WorkerToHostMethods["events.subscribe"][0]): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provides `http.fetch`. */
|
/** Provides `http.fetch`. */
|
||||||
@@ -261,6 +262,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
|||||||
|
|
||||||
// Events
|
// Events
|
||||||
"events.emit": "events.emit",
|
"events.emit": "events.emit",
|
||||||
|
"events.subscribe": "events.subscribe",
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
"http.fetch": "http.outbound",
|
"http.fetch": "http.outbound",
|
||||||
@@ -407,6 +409,9 @@ export function createHostClientHandlers(
|
|||||||
"events.emit": gated("events.emit", async (params) => {
|
"events.emit": gated("events.emit", async (params) => {
|
||||||
return services.events.emit(params);
|
return services.events.emit(params);
|
||||||
}),
|
}),
|
||||||
|
"events.subscribe": gated("events.subscribe", async (params) => {
|
||||||
|
return services.events.subscribe(params);
|
||||||
|
}),
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
"http.fetch": gated("http.fetch", async (params) => {
|
"http.fetch": gated("http.fetch", async (params) => {
|
||||||
|
|||||||
@@ -482,6 +482,10 @@ export interface WorkerToHostMethods {
|
|||||||
params: { name: string; companyId: string; payload: unknown },
|
params: { name: string; companyId: string; payload: unknown },
|
||||||
result: void,
|
result: void,
|
||||||
];
|
];
|
||||||
|
"events.subscribe": [
|
||||||
|
params: { eventPattern: string; filter?: Record<string, unknown> | null },
|
||||||
|
result: void,
|
||||||
|
];
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
"http.fetch": [
|
"http.fetch": [
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
* |--- request(initialize) -------------> | → calls plugin.setup(ctx)
|
* |--- request(initialize) -------------> | → calls plugin.setup(ctx)
|
||||||
* |<-- response(ok:true) ---------------- |
|
* |<-- response(ok:true) ---------------- |
|
||||||
* | |
|
* | |
|
||||||
* |--- request(onEvent) ----------------> | → dispatches to registered handler
|
* |--- notification(onEvent) -----------> | → dispatches to registered handler
|
||||||
* |<-- response(void) ------------------ |
|
|
||||||
* | |
|
* | |
|
||||||
* |<-- request(state.get) --------------- | ← SDK client call from plugin code
|
* |<-- request(state.get) --------------- | ← SDK client call from plugin code
|
||||||
* |--- response(result) ----------------> |
|
* |--- response(result) ----------------> |
|
||||||
@@ -387,6 +386,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||||
}
|
}
|
||||||
eventHandlers.push(registration);
|
eventHandlers.push(registration);
|
||||||
|
// Register subscription on the host so events are forwarded to this worker
|
||||||
|
void callHost("events.subscribe", { eventPattern: name, filter: registration.filter ?? null }).catch((err) => {
|
||||||
|
notifyHost("log", {
|
||||||
|
level: "warn",
|
||||||
|
message: `Failed to subscribe to event "${name}" on host: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
const idx = eventHandlers.indexOf(registration);
|
const idx = eventHandlers.indexOf(registration);
|
||||||
if (idx !== -1) eventHandlers.splice(idx, 1);
|
if (idx !== -1) eventHandlers.splice(idx, 1);
|
||||||
@@ -1107,6 +1113,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
const event = notif.params as AgentSessionEvent;
|
const event = notif.params as AgentSessionEvent;
|
||||||
const cb = sessionEventCallbacks.get(event.sessionId);
|
const cb = sessionEventCallbacks.get(event.sessionId);
|
||||||
if (cb) cb(event);
|
if (cb) cb(event);
|
||||||
|
} else if (notif.method === "onEvent" && notif.params) {
|
||||||
|
// Plugin event bus notifications — dispatch to registered event handlers
|
||||||
|
handleOnEvent(notif.params as OnEventParams).catch((err) => {
|
||||||
|
notifyHost("log", {
|
||||||
|
level: "error",
|
||||||
|
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
|
"hermes_local",
|
||||||
] as const;
|
] as const;
|
||||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
|
"hermes-paperclip-adapter": "0.1.1",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
"@paperclipai/plugin-sdk": "workspace:*",
|
"@paperclipai/plugin-sdk": "workspace:*",
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||||
} from "@paperclipai/adapter-pi-local";
|
} from "@paperclipai/adapter-pi-local";
|
||||||
|
import {
|
||||||
|
execute as hermesExecute,
|
||||||
|
testEnvironment as hermesTestEnvironment,
|
||||||
|
sessionCodec as hermesSessionCodec,
|
||||||
|
} from "hermes-paperclip-adapter/server";
|
||||||
|
import {
|
||||||
|
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||||
|
models as hermesModels,
|
||||||
|
} from "hermes-paperclip-adapter";
|
||||||
import { processAdapter } from "./process/index.js";
|
import { processAdapter } from "./process/index.js";
|
||||||
import { httpAdapter } from "./http/index.js";
|
import { httpAdapter } from "./http/index.js";
|
||||||
|
|
||||||
@@ -151,6 +160,16 @@ const piLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hermesLocalAdapter: ServerAdapterModule = {
|
||||||
|
type: "hermes_local",
|
||||||
|
execute: hermesExecute,
|
||||||
|
testEnvironment: hermesTestEnvironment,
|
||||||
|
sessionCodec: hermesSessionCodec,
|
||||||
|
models: hermesModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||||
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
[
|
[
|
||||||
claudeLocalAdapter,
|
claudeLocalAdapter,
|
||||||
@@ -160,6 +179,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
|||||||
cursorLocalAdapter,
|
cursorLocalAdapter,
|
||||||
geminiLocalAdapter,
|
geminiLocalAdapter,
|
||||||
openclawGatewayAdapter,
|
openclawGatewayAdapter,
|
||||||
|
hermesLocalAdapter,
|
||||||
processAdapter,
|
processAdapter,
|
||||||
httpAdapter,
|
httpAdapter,
|
||||||
].map((a) => [a.type, a]),
|
].map((a) => [a.type, a]),
|
||||||
|
|||||||
@@ -556,6 +556,18 @@ export function buildHostServices(
|
|||||||
}
|
}
|
||||||
await scopedBus.emit(params.name, params.companyId, params.payload);
|
await scopedBus.emit(params.name, params.companyId, params.payload);
|
||||||
},
|
},
|
||||||
|
async subscribe(params: { eventPattern: string; filter?: Record<string, unknown> | null }) {
|
||||||
|
const handler = async (event: import("@paperclipai/plugin-sdk").PluginEvent) => {
|
||||||
|
if (notifyWorker) {
|
||||||
|
notifyWorker("onEvent", { event });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (params.filter) {
|
||||||
|
scopedBus.subscribe(params.eventPattern as any, params.filter as any, handler);
|
||||||
|
} else {
|
||||||
|
scopedBus.subscribe(params.eventPattern as any, handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
@@ -1060,6 +1072,10 @@ export function buildHostServices(
|
|||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|
||||||
|
// Clear event bus subscriptions to prevent accumulation on worker restart.
|
||||||
|
// Without this, each crash/restart cycle adds duplicate subscriptions.
|
||||||
|
scopedBus.clear();
|
||||||
|
|
||||||
// Snapshot to avoid iterator invalidation from concurrent sendMessage() calls
|
// Snapshot to avoid iterator invalidation from concurrent sendMessage() calls
|
||||||
const snapshot = Array.from(activeSubscriptions);
|
const snapshot = Array.from(activeSubscriptions);
|
||||||
activeSubscriptions.clear();
|
activeSubscriptions.clear();
|
||||||
|
|||||||
@@ -75,11 +75,15 @@ export function CommandPalette() {
|
|||||||
enabled: !!selectedCompanyId && open,
|
enabled: !!selectedCompanyId && open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: projects = [] } = useQuery({
|
const { data: allProjects = [] } = useQuery({
|
||||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId && open,
|
enabled: !!selectedCompanyId && open,
|
||||||
});
|
});
|
||||||
|
const projects = useMemo(
|
||||||
|
() => allProjects.filter((p) => !p.archivedAt),
|
||||||
|
[allProjects],
|
||||||
|
);
|
||||||
|
|
||||||
function go(path: string) {
|
function go(path: string) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
queryFn: () => projectsApi.list(companyId!),
|
queryFn: () => projectsApi.list(companyId!),
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
const activeProjects = useMemo(
|
||||||
|
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
|
||||||
|
[projects, issue.projectId],
|
||||||
|
);
|
||||||
const { orderedProjects } = useProjectOrder({
|
const { orderedProjects } = useProjectOrder({
|
||||||
projects: projects ?? [],
|
projects: activeProjects,
|
||||||
companyId,
|
companyId,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
|
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||||
theme === "dark" && "prose-invert",
|
theme === "dark" && "prose-invert",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -288,8 +288,12 @@ export function NewIssueDialog() {
|
|||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
});
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const activeProjects = useMemo(
|
||||||
|
() => (projects ?? []).filter((p) => !p.archivedAt),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
const { orderedProjects } = useProjectOrder({
|
const { orderedProjects } = useProjectOrder({
|
||||||
projects: projects ?? [],
|
projects: activeProjects,
|
||||||
companyId: effectiveCompanyId,
|
companyId: effectiveCompanyId,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { DraftInput } from "./agent-config-primitives";
|
import { DraftInput } from "./agent-config-primitives";
|
||||||
import { InlineEditor } from "./InlineEditor";
|
import { InlineEditor } from "./InlineEditor";
|
||||||
@@ -34,6 +34,8 @@ interface ProjectPropertiesProps {
|
|||||||
onUpdate?: (data: Record<string, unknown>) => void;
|
onUpdate?: (data: Record<string, unknown>) => void;
|
||||||
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
|
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
|
||||||
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
|
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
|
||||||
|
onArchive?: (archived: boolean) => void;
|
||||||
|
archivePending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
|
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
|
||||||
@@ -152,7 +154,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [goalOpen, setGoalOpen] = useState(false);
|
const [goalOpen, setGoalOpen] = useState(false);
|
||||||
@@ -954,6 +956,45 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{onArchive && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||||
|
Danger Zone
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.archivedAt
|
||||||
|
? "Unarchive this project to restore it in the sidebar and project selectors."
|
||||||
|
: "Archive this project to hide it from the sidebar and project selectors."}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={archivePending}
|
||||||
|
onClick={() => {
|
||||||
|
const action = project.archivedAt ? "Unarchive" : "Archive";
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`${action} project "${project.name}"?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
onArchive(!project.archivedAt);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{archivePending ? (
|
||||||
|
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
|
||||||
|
) : project.archivedAt ? (
|
||||||
|
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
|
||||||
|
) : (
|
||||||
|
<><Archive className="h-3 w-3 mr-1" />Archive project</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
ui/src/index.css
119
ui/src/index.css
@@ -178,12 +178,13 @@
|
|||||||
background: oklch(0.5 0 0);
|
background: oklch(0.5 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auto-hide scrollbar: fully transparent by default, visible on container hover */
|
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
|
||||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
transition: background 150ms ease;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||||
background: oklch(0.205 0 0) !important;
|
background: oklch(0.205 0 0) !important;
|
||||||
@@ -411,30 +412,118 @@
|
|||||||
|
|
||||||
.paperclip-mdxeditor-content code {
|
.paperclip-mdxeditor-content code {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
font-size: 0.84em;
|
font-size: 0.78em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content pre {
|
.paperclip-mdxeditor-content pre {
|
||||||
margin: 0.5rem 0;
|
margin: 0.4rem 0;
|
||||||
padding: 0.45rem 0.55rem;
|
padding: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
border-radius: calc(var(--radius) - 3px);
|
border-radius: calc(var(--radius) - 3px);
|
||||||
background: color-mix(in oklab, var(--accent) 50%, transparent);
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
/* Dark theme for CodeMirror code blocks inside the MDXEditor.
|
||||||
Matches the editor theme so rendered code looks consistent. */
|
Overrides the default cm6-theme-basic-light that MDXEditor bundles. */
|
||||||
.prose pre {
|
.paperclip-mdxeditor .cm-editor {
|
||||||
border: 1px solid var(--border);
|
background-color: #1e1e2e !important;
|
||||||
border-radius: calc(var(--radius) - 3px);
|
color: #cdd6f4 !important;
|
||||||
background-color: color-mix(in oklab, var(--accent) 50%, transparent);
|
font-size: 0.78em;
|
||||||
color: var(--foreground);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose code {
|
.paperclip-mdxeditor .cm-gutters {
|
||||||
|
background-color: #181825 !important;
|
||||||
|
color: #585b70 !important;
|
||||||
|
border-right: 1px solid #313244 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-activeLineGutter {
|
||||||
|
background-color: #1e1e2e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-activeLine {
|
||||||
|
background-color: color-mix(in oklab, #cdd6f4 5%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-cursor,
|
||||||
|
.paperclip-mdxeditor .cm-dropCursor {
|
||||||
|
border-left-color: #cdd6f4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-selectionBackground {
|
||||||
|
background-color: color-mix(in oklab, #89b4fa 25%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-focused .cm-selectionBackground {
|
||||||
|
background-color: color-mix(in oklab, #89b4fa 30%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor .cm-content {
|
||||||
|
caret-color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MDXEditor code block language selector – show on hover only */
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"],
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"] select,
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] select {
|
||||||
|
background-color: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border-color: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeBlockToolbar_"],
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
|
||||||
|
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeBlockToolbar_"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
||||||
|
Dark theme code blocks with compact sizing.
|
||||||
|
Override prose CSS variables so prose-invert can't revert to defaults. */
|
||||||
|
.paperclip-markdown {
|
||||||
|
--tw-prose-pre-bg: #1e1e2e;
|
||||||
|
--tw-prose-pre-code: #cdd6f4;
|
||||||
|
--tw-prose-invert-pre-bg: #1e1e2e;
|
||||||
|
--tw-prose-invert-pre-code: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown pre {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent) !important;
|
||||||
|
border-radius: calc(var(--radius) - 3px) !important;
|
||||||
|
background-color: #1e1e2e !important;
|
||||||
|
color: #cdd6f4 !important;
|
||||||
|
padding: 0.5rem 0.65rem !important;
|
||||||
|
margin: 0.4rem 0 !important;
|
||||||
|
font-size: 0.78em !important;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown code {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
font-size: 0.84em;
|
font-size: 0.78em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove backtick pseudo-elements from inline code (prose default adds them) */
|
/* Remove backtick pseudo-elements from inline code (prose default adds them) */
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export function OrgChart() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-[calc(100vh-7rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
|
|||||||
@@ -274,6 +274,21 @@ export function ProjectDetail() {
|
|||||||
onSuccess: invalidateProject,
|
onSuccess: invalidateProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const archiveProject = useMutation({
|
||||||
|
mutationFn: (archived: boolean) =>
|
||||||
|
projectsApi.update(
|
||||||
|
projectLookupRef,
|
||||||
|
{ archivedAt: archived ? new Date().toISOString() : null },
|
||||||
|
resolvedCompanyId ?? lookupCompanyId,
|
||||||
|
),
|
||||||
|
onSuccess: (_, archived) => {
|
||||||
|
invalidateProject();
|
||||||
|
if (archived) {
|
||||||
|
navigate("/projects");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const uploadImage = useMutation({
|
const uploadImage = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||||
@@ -476,6 +491,8 @@ export function ProjectDetail() {
|
|||||||
onUpdate={(data) => updateProject.mutate(data)}
|
onUpdate={(data) => updateProject.mutate(data)}
|
||||||
onFieldUpdate={updateProjectField}
|
onFieldUpdate={updateProjectField}
|
||||||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||||||
|
onArchive={(archived) => archiveProject.mutate(archived)}
|
||||||
|
archivePending={archiveProject.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -22,11 +22,15 @@ export function Projects() {
|
|||||||
setBreadcrumbs([{ label: "Projects" }]);
|
setBreadcrumbs([{ label: "Projects" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const { data: projects, isLoading, error } = useQuery({
|
const { data: allProjects, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const projects = useMemo(
|
||||||
|
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||||||
|
[allProjects],
|
||||||
|
);
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||||
@@ -47,7 +51,7 @@ export function Projects() {
|
|||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{projects && projects.length === 0 && (
|
{!isLoading && projects.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Hexagon}
|
icon={Hexagon}
|
||||||
message="No projects yet."
|
message="No projects yet."
|
||||||
@@ -56,7 +60,7 @@ export function Projects() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projects && projects.length > 0 && (
|
{projects.length > 0 && (
|
||||||
<div className="border border-border">
|
<div className="border border-border">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<EntityRow
|
<EntityRow
|
||||||
|
|||||||
Reference in New Issue
Block a user