planestories
A CLI tool that bridges markdown-based user stories and Plane work items, enforcing user story and acceptance-criteria discipline for AI agent-driven development.
Attribution. planestories is a fork of linearstories by Ijonas Kisselbach / Stacking Turtles Ltd., adapted to target Plane instead of Linear. The original is MIT-licensed; that license is preserved in full (see
LICENSEandNOTICE). Huge thanks to the original author.
Why structured acceptance criteria matter for AI agents
AI coding agents — Claude Code, Cursor, Copilot Workspace, and others — perform dramatically better when given precise, testable acceptance criteria. Vague tickets like "improve the login flow" lead to ambiguous implementations and wasted iteration cycles. Structured user stories with explicit acceptance criteria give agents the deterministic guardrails they need:
- Clear scope boundaries. Each acceptance criterion is a discrete, verifiable condition.
- Testable by default. Criteria written as checkboxes (
- [ ] ...) map directly to test cases. - Markdown as the source of truth. Stories live in your repository alongside the code.
- Two-way sync with Plane. Managers keep their board current; agents keep their specs current.
Quick start
1. Install
Run directly with bunx (no install required):
bunx planestories import stories/*.md
Or install globally / build a binary:
bun install -g planestories
# or build from source:
bun install
bun build src/cli/index.ts --compile --outfile planestories
2. Provide credentials (never commit them)
Credentials live in a gitignored .env file — never in a committed config. Copy .env.example to .env and fill in:
PLANE_API_KEY=plane_api_xxxxxxxxxxxxxxxxxxxx # Plane > Profile Settings > Personal Access Tokens
PLANE_WORKSPACE_SLUG=your-workspace-slug # the app.plane.so/<slug>/... part of your URL
# PLANE_BASE_URL=https://api.plane.so # only override when self-hosting
3. Add non-secret defaults (optional)
Create .planestoriesrc.json in your project root for non-secret defaults. Do not put apiKey here — it comes from .env.
{
"workspaceSlug": "your-workspace-slug",
"baseUrl": "https://api.plane.so",
"defaultProject": "Q1 2026 Release",
"defaultLabels": ["User Story"]
}
PLANE_API_KEY, PLANE_WORKSPACE_SLUG, and PLANE_BASE_URL from the environment always override config-file values.
4. Write your first story
Create stories/login.md (see templates/user-story.md):
---
project: "Q1 2026 Release"
---
## As a user, I want to log in so that I can access my account
```yaml
plane_id:
plane_identifier:
plane_url:
priority: high
labels: [Feature, Auth]
estimate: 3
assignee: jane@company.com
status: Backlog
```
User should be able to log in with their email and password.
### Acceptance Criteria
- [ ] User can enter email and password on the login page
- [ ] Invalid credentials show a clear error message
5. Import
planestories import stories/*.md # create/update work items, write ids back
planestories import stories/*.md --dry-run # preview: reports exactly what apply would do, no writes
--dry-run is faithful — it consults the board read-only (one memoized listing) and reports the same per-story outcome apply would produce (would create / would update / unchanged / would skip a duplicate) — it just never writes. Add --check to also validate that each status / assignee / label / parent resolves.
After a successful import, plane_id (the work item UUID), plane_identifier (e.g. ENG-42), and plane_url are written back into each story's YAML block.
How fields map to Plane
| Story field | Plane |
|---|---|
project (per-story / frontmatter / --project) |
the work item's project (required — Plane has no "team" tier) |
## Heading |
work item name |
| body markdown | description_html (converted to HTML) |
priority |
urgent / high / medium / low / none (legacy Linear integers 0–4 are also accepted) |
status |
work item state (resolved by name within the project) |
labels |
label UUIDs (resolved by name within the project) |
assignee |
member UUID (resolved by email or display name) |
estimate |
story point |
plane_id |
work item UUID (used to update) |
plane_hash |
content hash of the last sync (auto-managed) — powers skip-unchanged |
parent |
nests this item under an existing one (parent: DATA-12; resolved by identifier) |
kind |
story / criterion / epic — informational; emitted by export, read on import |
comment |
optional evidence note posted once (idempotently) on create/update/status change |
Choosing the project
A workspace usually has several projects. You can target any of them at three levels of granularity (highest precedence first):
--project "Name"on the command — forces all stories in that run into one project.- Per-story
project:in a story's YAML block — routes that single story. - File frontmatter
project:— the default for every story in the file. defaultProjectin config — the fallback when nothing else is set.
So one file can fan stories out to different projects:
---
project: "True Cost" # file default
---
## A story that goes to Infrastructure Setup
```yaml
project: Infrastructure Setup # per-story override
```
...
## A story that uses the file default (True Cost)
...
A project can be given by its display name ("Infrastructure Setup") or its identifier
("INFRASETUP" — stable and typo-resistant). Run planestories projects to list both for your
workspace. An unknown name fails loudly and suggests the closest match plus the available list
(Project not found: "Infrastructure". Did you mean "Infrastructure Setup"? Available projects: ...).
Use --dry-run --check to validate routing before importing.
Idempotency, skip-unchanged & duplicates
On create, planestories stamps each work item with external_id (derived from the story title) and external_source: "planestories", then writes plane_id back into the file. Re-running the import updates that item by its plane_id — never duplicating. A story that has no plane_id but whose title matches an existing item is treated as a duplicate (see below), so a second file can't silently overwrite the first file's work item — link it explicitly with --adopt-duplicates (or add the plane_id) when that's what you intend.
- Skip-unchanged. Each synced story stores a
plane_hash(a hash of the rendered payload). On re-import, a linked story whose content is unchanged is reportedunchangedand makes zero API writes — so re-importing a large, mostly-static board is cheap. Cosmetic markdown reflow that renders to the same HTML doesn't count as a change.--forcere-imports regardless. (An edit made in the Plane UI while the file is untouched is intentionally not pulled back by import — that's a futuregroomreverse-sync's job.) - Warm export → import.
exportwritesplane_hashtoo, so re-importing an unedited exported file is all-unchanged(no blind description rewrites). For files that carry aplane_idbut noplane_hash(legacy or hand-authored), import reconstructs the board item from a single project listing and adopts the hash if the content already matches — one list call, never a per-item fetch. - Duplicate guard. Before creating a brand-new story, planestories checks whether an item with the exact same title already exists in the project (created by anyone). By default it skips with a warning (
duplicate of ENG-42 (Backlog)), so you never get accidental twins. Pass--adopt-duplicatesto link a single exact match instead (multiple matches are a hard error — setplane_idmanually), or--force-createto create anyway.
Identifying planestories items
Every created work item is stamped with external_source: "planestories" — that's how
delete/export --external-source and idempotent matching find them. That field is an
API field, though, and isn't shown in Plane's normal board views. If you also want a
visible marker, set a source label (opt-in, off by default): sourceLabel in
config, PLANE_SOURCE_LABEL in the environment, or --source-label <name> per run. When
set, every created item is tagged with that label (auto-created — no --create-labels
needed), so humans can see and filter "what came from planestories" in the Plane UI.
Missing labels
By default, labels that don't exist in the project are skipped with a warning (deduped, one line per label). Pass --create-labels to create them instead.
Acceptance criteria as sub-items (--sync-criteria)
By default a story's ### Acceptance Criteria checklist is stored in the work item's description. Pass --sync-criteria to instead sync each criterion to a Plane sub-item (a child work item). A - [x] maps to a completed-group state and - [ ] to an open state, so ticking a box in markdown moves the sub-item — and export --sync-criteria reconstructs the checklist from the sub-items' states. The mapping is idempotent (keyed per criterion), so re-imports update in place.
Commands
import
planestories import <files...> [options]
-c, --config <path> Config file path
--context <name> Select a named context from a multi-context config
-p, --project <name> Force all stories into this project (overrides frontmatter)
--create-labels Create labels that don't exist instead of skipping
--source-label <name> Tag every created item with this label (auto-created)
--sync-criteria Sync each acceptance criterion to a Plane sub-item
--status-only Update ONLY the state of already-linked items (skip unlinked)
--force Re-import even when content is unchanged (bypass skip-unchanged)
--adopt-duplicates Link a single exact-title match instead of skipping it
--force-create Create even when a same-title item exists (bypass the duplicate guard)
--strict Refuse headings with no YAML block and no acceptance criteria
--dry-run Preview without writing to Plane
--check With --dry-run, validate read-only (project/state/assignee/labels)
--no-write-back Skip writing Plane ids back into the markdown
--status-only is the mode for bulk state transitions (e.g. closing a batch of tickets): for every story that already has a plane_id it PATCHes only the state (from status:) and touches nothing else — no description re-render, no title/label clobber. Unlinked stories are skipped with a warning.
export
planestories export [options]
-o, --output <file> Output file (default ./exported-stories.md)
-p, --project <name> Project to export from (required if no defaultProject)
-i, --issues <ids> Comma-separated work item identifiers (e.g. ENG-8)
-s, --status <state> Filter by status (repeatable — keeps items matching any)
--open-only Only export open items (backlog/unstarted/started)
-a, --assignee <email> Filter by assignee email
-l, --label <name> Filter by label name
--external-source [src] Only export items planestories created (default: planestories)
--sync-criteria Reconstruct acceptance criteria from sub-items
--include-archived Include items carrying the 'archived' label (excluded by default)
Export converts Plane's HTML description back to markdown (headings and - [ ]/- [x] checklists survive a round-trip), and emits stories in ascending identifier order. It also emits parent/kind structure and writes plane_hash, so re-importing an unedited exported file is all-unchanged (see Idempotency, skip-unchanged & duplicates).
projects
List the projects in your workspace (identifier + name) — handy for first-run setup and for
choosing the right --project value.
planestories projects
set
Update fields on existing work items by identifier — handy for moving a card without editing YAML.
planestories set <identifiers...> [options] # e.g. set ENG-12 ENG-13 --status "In Progress"
-p, --project <name> Project (required if no defaultProject)
-s, --status <state> Set the state by name
--priority <level> urgent | high | medium | low | none
-a, --assignee <email> Set the assignee by email
delete
Delete (or archive) work items — scoped only, never a blunt project wipe. Either by the files' plane_ids (which clears plane_* back out as the inverse of import) or by external_source within a project.
planestories delete <files...> [options]
planestories delete --external-source [src] --project <name> [options]
--archive Archive instead of hard delete (applies an 'archived' label)
--archive-label <name> Label to apply when archiving (default: archived)
--dry-run Show what would be deleted, change nothing
-y, --yes Confirm deletion (required — without it, only the plan is shown)
--no-write-back Don't clear plane_* out of files after deletion
Archiving uses a label, not Plane's native archive (which is restricted to
completed/cancelled items). delete --archive applies the archived label (recoverable —
just remove the label; works on any state) and leaves the work item in place. Archived
items are excluded from export by default (pass --include-archived to see them).
groom
Reconcile a project (dry-run by default; --yes to apply). Keeps a board tidy as work
completes on it:
planestories groom --project <name> [--yes]
- Closes orphaned criterion sub-items — an open
--sync-criteriasub-item whose parent is Done/Cancelled is moved to a completed state, with an idempotent "auto-closed with parent" comment. Only planestories criterion sub-items are ever closed — a real child story of a done epic is never touched. - Reports duplicate-title work items and criterion sub-items whose parent no longer exists.
doctor
A read-only CI health check over the same analysis — prints board rot and exits non-zero on
findings (pass --no-fail-on-findings to just report):
planestories doctor --project <name>
Rating story quality — /rate-userstories
planestories ships a Claude Code skill that reviews a story file before you import it. Run it in any Claude Code session:
/rate-userstories stories/q1-2026.md
It classifies each issue (epic / user story), validates structure and epic→child hierarchy, scores each with a type-specific rubric (epics get their own goal-clarity / scope / rationale rubric), and — critically for agentic coding — detects contradictions within and across issues, emitting corrected replacement markdown you can review. Vague, unfalsifiable, or self-contradicting acceptance criteria fail with concrete rewrites. It reads both inline ### Acceptance Criteria and exported kind: criterion sub-items. Full reference: docs/RATE_USERSTORIES.md.
Adapted from linearstories'
/rate-userstoriesskill; the epic-aware upgrade follows an enhancement by Ijonas Kisselbach.
Reliability
Every Plane API call retries transient failures automatically — HTTP 429 (honoring Retry-After), 5xx, and network blips — with exponential backoff plus jitter (capped at 30s). So a large bulk import or close won't fall over on a rate limit. Tune the retry budget with PLANE_MAX_RETRIES (default 5; 0 disables). After the retries are exhausted the error surfaces, and per-story failures never abort the run — the summary lists the failed items.
Self-hosting
planestories works against Plane Cloud by default (https://api.plane.so). To target a self-hosted instance, set PLANE_BASE_URL (env) or baseUrl (config) to your instance URL — no code changes required.
Multiple workspaces (contexts)
A config file may define named contexts; select one with --context <name>:
{
"contexts": [
{ "name": "orgA", "workspaceSlug": "org-a", "defaultProject": "Q1 Release" },
{ "name": "orgB", "workspaceSlug": "org-b", "defaultProject": "Brand Refresh" }
]
}
Development
bun install
bun test # run the test suite
bun run lint # biome
License
MIT. See LICENSE (original Stacking Turtles Ltd.) and NOTICE for attribution and modification copyright.