RFD 116: Shareable RFD, Ticket, and Site Tooling
Summary
The RFD and ticket workflow moves into three jp command plugins that a project adopts independently. jp ticket manages tickets, jp rfd manages RFDs, and jp vite bootstraps and updates the VitePress pages that render both. Each reads its settings from JP's configuration, and the first two also serve tools to the assistant. A project that adopts them gets every later change by upgrading binaries rather than by porting files.
Motivation
The workflow lives in four places today:
- The ticket format and store in
crates/internal/ticket, and thejp ticketcommand plugin incrates/plugins/command/ticket. - The
ticket_*tool handlers in.config/jp/tools/src/ticket.rs, insidejp-toolsnext to unrelated file, git, and web tools. - About 25
rfd-*recipes and their helpers in the justfile, written in shell withjq,sed,comfort, andjpas dependencies. - The loaders, components, and styles under
docs/.vitepress/.
The recipes have no tests, and the site's tests cover only its metadata reader. Only the first two can be shared without copying.
This workflow is useful in other projects too, and right now the only solution is to copy all of the above files and recipes into each project, having to sync them again when the workflow changes. Not every project wants all of it either: one tracks tickets without writing RFDs, another has no documentation site.
The workflow keeps changing, since a new way of writing and reviewing RFDs is in development, so the copies would fall behind faster, not slower.
Design
Overview
| Plugin | Crate | Owns |
|---|---|---|
jp ticket | crates/plugins/command/ticket | Tickets: commands, the board, and the ticket_* tools |
jp rfd | crates/plugins/command/rfd | RFDs: lifecycle, board, and the rfd_* tools |
jp vite | crates/plugins/command/vite | The site: init, sync, and its files |
Each is a jp-<name> binary, built as a library with a thin main the way jp-ticket is today, and each runs only as a plugin: jp resolves the configuration and hands it over in the init message (RFD 072). jp-ticket exists; jp-rfd and jp-vite are new. The ticket format stays in crates/internal/ticket, and jp_label stays a separate crate, because conversations use it too.
Each declares in its manifest the lowest plugin protocol it needs: 12 for jp ticket and jp rfd, which serve tools, and 10 for jp vite, which reloads configuration. One thing is new in jp: protocol 13 adds edit_files, which opens files in the user's editor for jp ticket edit. jp ticket checks for it when it needs it, so a jp speaking 12 still runs every other command.
The dependencies run one way:
jp ticketknows nothing about RFDs. An RFD appears in a ticket only as an opaque id: inPromoted to, and in the labelsjp rfdreads as links to an RFD (see RFDs).jp rfdlinks theticketlibrary for the two things that cross over: tracking tickets, and seeding a draft from a ticket. Both plugins write through that library, so the ticket format still has one implementation.jp vitelinks neither. Its dev server makes every ticket write throughjp ticket, including the board moves it writes itself in JavaScript today, and every RFD write throughjp rfdexcept the priority board's (see Site). Running the site no longer needsjust.
| Entry point | Caller |
|---|---|
jp ticket …, jp rfd …, jp vite … | A person at the terminal |
plugin.command.ticket.<tool>, plugin.command.rfd.<tool> | The assistant, through plugin tools |
jp ticket …, jp rfd … | The site's dev server, to write |
Configuration
Each plugin's settings live under its plugins.command.<name>.options:
[plugins.command.ticket]
run = "allow"
[plugins.command.ticket.options]
dir = "docs/ticket"
# The author written on tickets and comments filed through a tool.
assistant = "jp"
# How many Done tickets a board shows.
board.done_limit = 8
[plugins.command.ticket.options.labels.type]
description = "The type of ticket"
values = ["bug", "feature", "tracking"]
[plugins.command.rfd]
run = "allow"
[plugins.command.rfd.options]
dir = "docs/rfd"
categories = ["design", "decision", "guide", "process"]
# The label a tracking ticket names its RFD under, and the labels it is filed
# with besides.
tracking.key = "tracking-rfd"
tracking.labels = ["type=tracking"]
# The label any other ticket names an RFD its work belongs to under.
relevant.key = "relevant-rfd"
[plugins.command.vite]
run = "allow"
[plugins.command.vite.options]
# The VitePress source directory.
dir = "docs"
# Where links to repository files point.
repository = "https://github.com/dcdpr/jp"Every option is optional, and the values shown are the defaults except four: assistant defaults to assistant, labels to no vocabulary, tracking.labels to none, and repository to git's origin remote. A board without a vocabulary takes any key or key=value label; one with a vocabulary takes only the labels it defines.
Each setting has one owner. A plugin that needs another's setting reads it from the full configuration in init: jp rfd takes the ticket directory and vocabulary from plugins.command.ticket.options, and jp vite reads all three plugins' options.
The label vocabulary moves out of .labels.json into options.labels, in the same shape: a description per key, its values, and the values retired from it. A key can also bound how many of its values one ticket carries, with min and max, so min = 1, max = 1 makes every ticket carry exactly one. values = [] makes a key take any value, such as an RFD number, and unique holds a key ("key") or each of its values ("key-value") to one ticket on the board. Whether contributors may redefine it in their own config layers is the workspace's call, as for any other setting.
run = "allow" admits a plugin without asking. Every plugin defaults to ask, and a run with no terminal to ask on, such as a dev-server write, refuses a plugin that is neither allowed nor approved with jp plugin approve. An approval is pinned to the binary's contents, so a project that rebuilds the plugins often, as JP does, sets allow instead. A plugin's tools are admitted once when a turn starts, where a terminal usually is.
Both document directories must sit inside the VitePress source directory, because VitePress serves pages from nowhere else, and jp vite refuses a configuration that breaks this. A directory's route follows its path: with dir = "docs", docs/ticket is served at /ticket/.
Layered options merge key by key, so a contributor who overrides assistant keeps the workspace's dir.
Tickets
The commands are the existing jp ticket commands, with these changes:
edittakes--titleand a repeatable--labelbeside--bodyand--status, so a script changes several parts in one command. With none of them, it opens the ticket's file in the user's editor, through the host'sedit_files(protocol 13), and checks the file when the editor closes.renameandlabelstay, as the commands to find for changing one part.labeladds and removes labels rather than replacing them, so a write never drops a label it doesn't name: itsaddkeeps what a ticket carries,setreplaces what the keys it names hold,rmtakes a label or, for a bare key, every label under it, andlslists them. The labels are bare arguments, so--idnames the tickets, on either side of the verb; repeat it, or separate ids with commas, to change several tickets at once, all of them or none. Without it, the user picks one. A label written as a ticket id,T-02wt0kx, is refused, since it was meant as a ticket.edit --labelstays the one write that sets the whole list, and names the labels it removed.status ID STATUSmoves a ticket to any column;closestays as the shorthand for Done. With--beforeor--afternaming a ticket already in that column, it lands next to that ticket; the neighbour is checked before anything is written.- The
Blocked byfield is dropped, with nothing in its place: a project that marks blocked tickets does it with a label. Nothing read the field beyond displaying it. - The
Kindfield is dropped too, withadd's kind argument and picker,--kindonedit,list, andimport, the kind column oflistandboard, and the tools'kindparameters. What kind of work a ticket is becomes a label, so each board defines its own kinds; JP's istype, which every ticket carries once (min = 1, max = 1).importtakes--labelfor the tickets it creates. boardprints the board: Todo, In Progress, and Done in turn, each in board order, with Done capped atboard.done_limit. The columns are stacked rather than side by side, which would wrap below about 120 terminal columns.rank ID (--before | --after) OTHERmoves a ticket next to another in the same column.importtakes the repository on every call, as--repo OWNER/NAME. It is rarely used, and a project that wants a default writes a wrapper.initcreates the ticket directory.migrateis dropped. Every ticket in JP already uses the collision-resistant identifiers of RFD 102, and no other project has tickets to migrate.
The board order stays in .board.json in the ticket directory. board and ticket_list read it; rank, status --before|--after, and the site's board write it. A ticket the file doesn't name sorts below the ranked ones, so the file needs no setup and init doesn't create it.
promote ID --to RFD keeps its meaning: it links an RFD that already exists and closes the ticket. Creating the RFD is jp rfd's job.
RFDs
The commands are the twelve lifecycle recipes this RFD ports, plus show, rank, and init:
jp rfd draft CATEGORY TITLE [--from-ticket ID]
jp rfd promote ID
jp rfd extend ID TARGET
jp rfd require ID TARGET
jp rfd supersede ID BY
jp rfd abandon ID REASON
jp rfd renumber ID [NEW]
jp rfd rename ID TITLE
jp rfd track ID
jp rfd start ID
jp rfd stop ID
jp rfd list [CATEGORY]
jp rfd show ID [--body]
jp rfd rank ID (--before OTHER | --after OTHER | --backlog)
jp rfd initThe recipes are the reference for what each command does to the repository, which files change and how, not for how it does it. They grew one at a time: ids are resolved in at least three places (_rfd-resolve, a helper inside _rfd-link, and inline in rfd-extend), and every recipe that touches the metadata header edits it with its own sed or awk. The port has one id resolver, one metadata-header editor, and one relationship writer behind extend, require, and supersede, and it simplifies an interface wherever the recipe's shape came from shell rather than from the workflow. An outcome that changes on purpose is listed in this RFD before it lands. The ones made here:
draft --from-ticketreplacesjust ticket-promote.- A tracking ticket names its RFD in a label,
tracking-rfd=116, and any other ticket whose work belongs to an RFD in another,relevant-rfd=116, where the recipes used anImplementsfield and atype=trackinglabel. Both keys are settings,tracking.keyandrelevant.key, as are the labels a tracking ticket is filed with besides,tracking.labels.renumberrewrites both labels along with the RFD's id, which the recipe never did forImplements. extendtakes its arguments in the same order asrequire, source first.jp rfd extend 116 100records that 116 extends 100, andjp rfd require 116 072that 116 requires 072.just rfd-extend NNN MMMreads the other way round, meaning MMM extends NNN. A relationship written backwards still parses and silently changes the promotion gates, so the two commands share one order.promoteto Implemented removes theRequiresentries that name the promoted RFD from every dependent, drafts included. The recipe finds dependents through the promoted RFD'sRequired byline, which never lists a draft, so a draft'sRequiressurvives and fails the docs build. The port finds dependents by reading every RFD'sRequires.abandonwrites the notice block RFD 001 specifies, where the recipe added a one-line> **Abandoned**:note.- A field a command adds goes above
Summary, which RFD 001 keeps last. The recipes putSuperseded byandSupersedesafterDate. - An RFD added to a relationship field that wraps goes after its last line; the recipe appended it to the first, inside the wrapped value.
supersedeadds to an RFD'sSupersedesrather than replacing it, so an RFD that supersedes two keeps both.- A title is written as given. The recipe built the heading with
sed, so a/or&in a title broke it, andAUTHORorDATEin one was replaced. - A rewritten reference matches the whole slug: a longer slug that starts with the old one is left alone, where the recipes rewrote its start.
renamerewrites a link written without.mdtoo, asrenumberandpromotedo. jp -F json rfd listgives each RFD'sfile, relative to the workspace, where the recipe gave its sitepath:jp rfddoesn't know where a site serves it. Neither plugin has a--jsonflag:jp -F jsonmakeslistandshowprint JSON.- Ids are read leniently, so
42,RFD 042,d5, andD5each name an RFD. The recipe found no draft byD5. - A missing
drafts/directory holds no drafts, and an unreadable.priority.jsonis reported, with every RFD listed as unranked.just rfd-listfailed on the first and read the second as empty. - Accepting an RFD with nobody to ask files no tracking ticket, where the recipe filed one: the host answers a prompt nobody saw the same way as one cancelled.
trackuses the first of two tracking tickets by id and warns, naming both, where the recipe took the first silently.
Three delegations carry over:
promotereflows the promoted file withcomfortand offers to file a tracking ticket at Accepted.comfortbecomes a library call rather than a subprocess.trackreads the Implementation Plan by asking the host for a structuredqueryin a conversation that expires (schemaandexpires_in, protocol 11), and writes the<!-- rfd-plan: pending -->marker when that fails. The recipe'sjp querysubprocess goes.listneeds the board: RFD metadata,.priority.json, and which tracking tickets are In Progress. TodayassembleBoardinrfd-shared.mjsbuilds it.jp rfdports the reading side of that function and none of its validation.
draft --from-ticket ID seeds the draft from the ticket's title and description, then records Promoted to on the ticket and closes it. It is safe to repeat: a ticket that already names an existing RFD reports it, and a draft seeded by an interrupted run is found by its Promoted from ticket line and linked rather than seeded twice.
rank ID --before OTHER and --after OTHER put ID next to OTHER on the priority board, in OTHER's milestone group, so a backlog RFD ranked this way becomes planned; rank ID --backlog moves it back. It refuses an RFD at a terminal status, which the board does not place. Milestones are created, renamed, and removed on the site.
init creates the RFD directory with the templates, an empty .priority.json, and a drafts/ directory. It does not write an RFD 001: the process document is each project's own.
Tracking needs tickets. In a project without a ticket directory, promote skips the tracking offer, list shows no RFD in development, and track, start, stop, and draft --from-ticket fail with a message saying so. A board that defines labels has to define the tracking key, open to any value (values = []), for a ticket to carry it: track and start refuse until it does, printing the entry to add, and promote skips the offer and says why. show lists the tickets naming an RFD, the tracking one first, and names the labels a missing tracking ticket takes.
Parity
Every verb that applies to both document kinds exists for both, and every command that makes sense for the assistant exists as a tool. Bold entries are new.
| Verb | jp rfd CLI / tool | jp ticket CLI / tool |
|---|---|---|
| Create | draft / rfd_draft | add / ticket_create |
| Show | show / rfd_show | show / ticket_show |
| List | list / rfd_list | list / ticket_list |
| Board | list | board / not a tool |
| Rank | rank / not a tool | rank / not a tool |
| Rename | rename / rfd_rename | rename, edit / ticket_edit |
| Edit body | not applicable | edit / ticket_edit |
| Change status | promote / rfd_promote | status, close / ticket_edit |
| Relate | extend, require, supersede / same | not applicable |
| Retire | abandon / rfd_abandon | delete / not a tool |
| Renumber | renumber / rfd_renumber | not applicable |
| Track | track, start, stop / ticket_create, ticket_edit | not applicable |
| Discuss | not applicable | comment / ticket_comment |
| Label | not applicable | label, labels, edit / ticket_edit |
| Ticket to RFD | draft --from-ticket / rfd_draft with ticket | promote --to / not a tool |
| Tool definitions | tools init, tools sync | tools init, tools sync |
Tracking an RFD is ticket work, so the assistant does it with the ticket tools: rfd_show names the labels a missing tracking ticket takes, ticket_create files it, and ticket_edit moves it to start or stop the work. Tools of its own would cost three definitions on every turn for that.
One tool, ticket_edit, makes every change to a ticket: it takes any of title, description, status, add_labels, and remove_labels, and changes only what it is given. Labels are added and removed rather than replaced, so a call can't drop one it doesn't name, and a key a ticket carries one of changes in one call: the bare key in remove_labels, the new label in add_labels. Each tool a model is offered costs context on every turn, and providers cap how many it can be offered; a command costs nothing until it runs, so the CLI keeps its separate commands.
For the same reason, ticket_show returns a ticket's metadata unless the call asks for its description or comments, and ends by naming what it left out. There is no tool listing the label vocabulary: the label parameters of ticket_create, ticket_edit, and ticket_list already offer every value. ticket_list returns tickets in board order, so its first Todo line is the next ticket to work on. It pages a long listing, and its query finds the tickets whose title, description, or comments mention a piece of text.
import, refresh, delete, board, and rank stay CLI-only. import reaches GitHub over the network, refresh rewrites what the current branch introduced, and deleting and prioritizing are the user's calls. board would tell the assistant nothing ticket_list doesn't.
A tool never opens the host's composer or picker. A missing argument is an error naming the argument.
Tools
jp ticket and jp rfd serve their tools as plugin tools (RFD 072). JP starts the plugin for each call and hands it the call in init, together with the plugin's options as the query resolved them: nested .jp.toml files, conversation configuration, and --cfg all reach it. ticket.rs leaves jp-tools.
[conversation.tools.ticket_create]
source = "plugin.command.ticket.create"
style.parameters = "tool"The plugin names its tools without a prefix (create, show, list), since the source already names the plugin. style.parameters = "tool" has the plugin describe a call before it runs, which is how ticket_create previews the ticket it will write today. A question a tool asks ends the call, and JP runs the plugin again with the answer, so the plugin keeps no state between calls. The tools keep their current behavior, including ticket_create asking for a shorter title when the title would not fit the filename, and options.max_title_length. Every tool holds to the call's access policy, refusing to read or write a document directory the policy doesn't grant, which the jp-tools handlers never checked.
The plugin owns what each tool does; each project owns its tool configuration. A project can expose create as track, choose its own summary text, and set its own options.max_body_words. The source name is the contract between the two: [conversation.tools.track] with source = "plugin.command.ticket.create" reaches the plugin as create.
tools init PATH writes a starting set of definitions into the directory PATH names, one file per tool. It refuses to overwrite a file that exists. PATH has no default, because where a project keeps its tool configuration is its own layout; the workspace configuration has to load that directory for the tools to appear.
tools sync PATH regenerates what the definitions mirror from configuration: the label tokens offered by the ticket_* tools, and the categories offered by rfd_draft and rfd_list. It finds the tools by their source, not their key: a tool keyed ticket_create and one keyed track, both with source = "plugin.command.ticket.create", get the tokens, each under its own key. It writes only into a tool whose definition declares the parameter, and names the ones it leaves out: values alone would make a parameter without a type, which drops the tool from every conversation. When the board bounds a key, it describes the bounds beside the labels of ticket_create and ticket_edit, and marks ticket_create's labels required when a key needs one, so the assistant knows before its first call. It rewrites only the file it generated, and rewrites it whole, so edits to the definitions survive and a tool removed from the configuration drops out of it. It replaces just ticket-labels-sync.
Both commands go once a plugin can describe the tools it serves (T-0vmm72y).
Site
jp vite init creates a VitePress setup in the source directory of an empty or existing repository: package.json, .vitepress/config.mts, the theme entry, and a homepage. It asks for the site's title and description through the host's composer, and writes them into config.mts and the homepage, so a new site does not start out as JP's. Like the other init commands, it refuses to overwrite a file that exists.
The site files split into two groups:
- Owned by
jp vite. Everything under.vitepress/jp-vite/: the loaders for both document kinds, components, styles, the dev-server middleware, a config fragment, and the settings snapshot. - Owned by the project.
config.mts, the theme entry, the homepage, and every page, the RFD and ticket pages included.config.mtsimports the fragment and merges it; the theme entry registers the components fromjp-vite/theme.initwrites both once and never touches them again.
sync creates the pages for each workflow the project has when they are missing: the index, priority, and drafts index pages in the RFD directory, and the index and board pages in the ticket directory. A page that exists is never changed, so a project writes its own around the list. Each page renders its list with one tag the theme entry registers, so it reads the same wherever the page sits:
<RfdIndex />and<RfdDraftIndex />, the published and the draft RFDs.<RfdBoard />, the priority board.<TicketIndex />and<TicketBoard />, the tickets and their board.
Both loaders ship together, because the RFD board reads tracking tickets. Without a ticket directory the ticket loader reads no tickets, as it does today.
The owned files live in the crate, under crates/plugins/command/vite/site/, and are built into the binary. JP syncs from them like any other project, so a change to a component is made in the crate, not in docs/.vitepress/jp-vite/.
The loaders also run without jp: vitepress build in CI reads the documents, filters them by category, and validates their labels, with no configuration to resolve. sync therefore writes what they need into the owned jp-vite/settings.json: both directories, the categories, the label vocabulary, the Done limit, the repository, and the revision jp-vite was built from. A link to a repository file points at the default branch (/blob/HEAD/), since repository names no branch; JP's site links to main today.
jp vite sync rewrites the owned files from the binary, the snapshot among them, and creates the pages a workflow is missing, including one added since the last sync. It lists the files it will write and asks once before writing any of them. It requires the project to be in git, and refuses when an owned file is modified or untracked, since git cannot restore an edit it never recorded. --force overwrites them anyway. On a clean tree, git diff shows what the sync changed and git checkout restores the last committed version. --yes skips the question for scripts; it does not imply --force.
sync --check writes nothing, and fails when any owned file differs from what sync would write, or a page is missing. A CI job with jp installed runs it to catch a configuration change committed without its sync.
jp vite serve runs the dev server. It runs the checks in Version Skew, starts vitepress dev as its child, and stops it before it exits. While it runs, it asks the host for the configuration again every two seconds (read_config with reload, protocol 10) and rewrites the snapshot when the settings in it change, so a changed directory or vocabulary reaches the site without a restart. The loaders watch the snapshot, and the middleware reads it on every request. The snapshot holds nothing written by hand, so this rewrite skips the refusal sync makes for a modified owned file.
The middleware accepts writes only under jp vite serve, which marks the child's environment; a dev server started another way is read-only. Every write passes the snapshot's directories as --cfg overrides, so a configuration changed between two polls cannot move a write away from the directory the site displays. The RFD priority board writes .priority.json itself, into the RFD directory the snapshot names: it posts the whole layout, milestones included, which no jp rfd command takes.
A drag on the ticket board is one command: jp ticket status, with --before or --after naming the card it landed next to, so the column and the place in it change together or not at all. Only the moved card gets a rank, where the board writes every column's whole order today.
The priority board writes no ticket. It shows which RFDs are in development, and links each RFD with tickets to the ticket board narrowed to them, ?any-label=tracking-rfd=116 and &any-label=relevant-rfd=116 with the configured keys. The ticket board filters by labels and knows nothing about RFDs: ?label= keeps the tickets carrying every term, ?any-label= those carrying any. Work starts by dragging the tracking ticket to In Progress there. A dev-server request runs jp without a terminal, and one still running after 15 seconds is stopped.
Styling and component changes therefore land in jp vite, and every project receives them on its next sync.
Version Skew
The synced loaders read what jp ticket and jp rfd write, so plugins installed at different revisions can disagree about a format. Installing them in one command at one revision avoids this (see Installation).
When they still differ, the site says so rather than misreading. Before it starts vitepress dev, jp vite serve runs two checks:
- It compares its own revision with the one
jp ticketandjp rfdreport, for each workflow whose directory exists. A plugin missing for an existing directory fails the check, naming the command that installs it; a tickets-only project never needsjp rfd. - It compares every owned file but the snapshot with what
syncwould write, so loaders left behind by an upgradedjp-viteare caught.
On a failure, the site still starts: the RFD and ticket pages show the error and the command that fixes it in place of their content, and the dev server refuses writes.
Validation
The site's validation stays in JavaScript for now: duplicate ids, the relationship graph and its status gate, board consistency, summaries, unknown labels, checked against the vocabulary in the snapshot, and link labels that name no RFD, or name one by an id spelled another way. The link check runs with the RFD loaders, which read the tickets anyway, so the ticket loaders stay free of RFDs. The loaders that run it are owned files, so jp vite sync distributes them unchanged.
A new way of writing and reviewing RFDs is in development and brings its own checker. Porting validation to Rust waits until that has settled, and gets its own RFD.
The ticket rules are the exception, because they already live in Rust: every label write is held to the vocabulary, its bounds, and unique. jp ticket check holds every ticket to them with the same code, so a project catches a ticket edited by hand, or filed before a rule existed, without building its site. The ticket loaders keep their JavaScript copy, which vitepress build runs without jp.
Installation
cargo install --git https://github.com/dcdpr/jp --rev <sha> jp-ticket jp-rfd jp-viteA project installs the plugins it uses alongside jp, in one command so they share a revision. JP installs them from the workspace, as it installs jp-ticket today. Pinning a revision makes an upgrade a deliberate change, reviewed alongside the jp vite sync it triggers.
Drawbacks
- The port is large. The twelve recipes and their helpers span roughly 1,500 lines of shell whose outcomes have to be reproduced, edge cases included:
renumberalone handles shared ids after a branch merge, draft-only tokens, and references outsidedocs/. - JP or nothing. A project uses these through
jpor not at all. Every dev-server write starts ajpprocess, and every tool call starts the plugin. - Three versions to keep in step. A project that upgrades one plugin and not the others can hit a format the older one misreads. The dev server's check reports it, but only after the fact.
- Settings in two places. The site reads a snapshot of the configuration.
jp vite servekeeps it current while it runs; a configuration committed without its sync stays stale untilsync --checkin CI catches it. - Two languages remain. Until validation moves to Rust, a rule about RFDs can live in a plugin or in the synced JavaScript, and both ship from here.
- Projects follow JP's repository. A project pins a JP revision to get a tooling fix, and JP's pace of change on
mainbecomes theirs.
Alternatives
One binary. A single jp-todo absorbing the ticket crate and plugin, with rfd, ticket, and docs subcommands. It has one version, so nothing can skew. But every project installs all of it, jp ticket becomes jp todo ticket, and one binary owns three concerns that change for different reasons. This flips if skew causes more trouble in practice than the split saves.
Standalone binaries. jp-ticket, jp-rfd, and jp-vite running without jp, configured by their own .jp-todo.toml. That is a second configuration file for all three plugins and the site to find and agree on, and without a host there is no composer or picker to prompt with. This flips when a project wants the workflow without JP.
The vocabulary stays in .labels.json. One file the static build reads directly. But it is a second home for configuration, and the snapshot carries the vocabulary to the build anyway.
Keep copying. The status quo, and the drift described in the Motivation is its result.
A shared justfile module. just imports local files only, so the module would still be copied into each project, still be shell, and still be untested.
A separate repository. The plugins depend on jp_plugin, jp_tool, jp_label, and comfort. A separate repository needs all four published and versioned, for one consumer outside JP. This can change once a second consumer needs these plugins outside JP's repository.
An npm package for the site. It would ship the components and styles the way a VitePress project expects, but versioned apart from the plugins, outside the one cargo install that keeps all three on one revision.
A three-way merge on sync. Recording the hash of each written file lets jp vite sync tell a local edit from an upstream change. Refusing to overwrite modified files, and git for everything committed, cover the same cases without a manifest to keep, so v1 does not add one.
Non-Goals
- Porting validation. See Validation.
- The remaining recipes.
rfd-board-pruneand the rest stay in JP's justfile until a later change moves them. - A standalone mode. See Alternatives.
- Editing milestones from the terminal. See RFDs.
- Migrating tickets. No project has tickets left to migrate.
- Publishing to crates.io.
Risks and Open Questions
- Builds without
jp. A CI build of the site that has nojpruns neither the revision check norsync --check. There, a skewed or stale loader fails the build or misreads a document with no hint at the cause. - Moving a directory. Changing
dirmoves nothing: the documents, and every link to them, are the project's to move. - Polling.
jp vite servereloads the configuration on a timer, because the host cannot yet say when it changed (T-0vmm72x). A configuration file saved halfway fails to load; the host keeps the last one that loaded, and so does the snapshot. - VitePress versions. Synced files assume a VitePress version.
jp vite initwritespackage.json, butsyncleaves it alone, so a project can fall behind what the synced files need.
Implementation Plan
Phase 1: jp ticket configuration and commands
Read dir, assistant, and board.done_limit from the plugin's options. Add rename, status, block, board, rank, and init; drop migrate; make import require --repo, and pass it from the ticket-import recipe. Serve the ticket_* tools as plugin tools, with ticket_create's title question and preview, add tools init and tools sync, and remove ticket.rs from jp-tools and just ticket-labels-sync. Point JP's ticket tool configuration at plugin.command.ticket.
The vocabulary stays in .labels.json until Phase 5, which gives the site a way to read it from configuration. JP keeps the default ticket directory, so the site's fixed paths hold until then too.
Mergeable on its own.
Phase 2: Reading RFDs
Create jp-rfd, reading dir and categories from its options. Port the board's reading side from rfd-shared.mjs. Add list, show, tools init, and tools sync, and the tools for list and show.
Depends on Phase 1.
Phase 3: RFD lifecycle
Write the shared id resolver, metadata-header editor, and relationship writer. Port draft, rename, renumber, extend, require, supersede, abandon, and promote, and their tools, and add rank and init. Port just ticket-promote into draft --from-ticket. JP's recipes become one-line calls into jp rfd, so both paths stay usable while the port is checked against real use; just rfd-extend NNN MMM calls jp rfd extend MMM NNN. The rfd_extend tool's parameters are renamed to match the new order.
Depends on Phase 2.
Phase 4: Tracking
Port track, start, and stop, and their tools, with track reading the plan through the host's query. Point the dev server's /__rfd-development endpoint at jp rfd.
Depends on Phase 3.
Phase 5: Site
Create jp-vite. Move JP's site files into crates/plugins/command/vite/site/, embed them, and add init, sync, sync --check, the settings snapshot, and serve with its checks, and point JP's docs dev recipe at jp vite serve. Point the ticket board writer at jp ticket status and jp ticket rank, and the priority writer at the snapshot's RFD directory. The /__rfd-development endpoint stays on the recipes until Phase 4 moves it. Move the label vocabulary from .labels.json into plugins.command.ticket.options.labels. JP checks its synced copy with a jp-vite test, jp_site_is_synced, which CI runs whenever the site files, the synced copy, or .jp/config.toml change; sync --check in the site workflow would build jp on every docs change. JP's own site becomes the first project synced from the binary.
Depends on Phase 2, so the revision check finds a jp rfd for JP's RFD directory. Independent of Phases 3 and 4.
Phase 6: Retire the recipes
Remove the ported recipes, ticket-promote among them, and point JP's RFD tool configuration at plugin.command.rfd. JP keeps just rfd-promote as a wrapper around jp rfd promote, because publishing a draft also archives the review and triage conversations JP's own workflow titles after the draft id, and its rfd_promote tool keeps calling the recipe for the same reason. The remaining ticket-* recipes keep calling jp ticket, which still works. Update the references in RFD 001 and RFD 100 to just rfd-*, just ticket-promote, just ticket-labels-sync, and .labels.json.
Depends on Phases 1 to 5.
References
- RFD 001: The JP RFD Process, the lifecycle
jp rfdimplements. - RFD 072: Command Plugin System, the protocol the plugins run under, the
initmessage that carries their configuration, and the tool calls they serve. - RFD 077: Plugin Configuration and Trust Policy, which describes the
optionsandrunsettings the plugins are configured through. - RFD 100: In-Repo Ticket Tracking, the ticket format and labels.
- RFD 102: Collision-Resistant Ticket Identifiers.