Issues and the timeline

fp2’s issue workflow is durable capture, server-authoritative status transitions, a typed timeline, opt-in exclusivity through claims, and handoff. It does not compute readiness.

Create an issue

Choose a stable retry ID before the first request:

fp2 issue create \
--title "<short-title>" \
--body "<markdown-context-and-acceptance-notes>" \
--priority <urgent|high|medium|low> \
--client-id "<stable-retry-id>" \
--json

The server creates the canonical 32-letter issue ID and derives its short FP-xxxxxxxx display reference.

Retrying the same client_id and identical payload returns the original issue. Reusing that ID with a changed payload is a conflict.

To create a child under an existing issue, add --parent <issue-reference>. One relation carries epics, tasks, and subtasks; see Issue hierarchy for the rules and the close gate.

Inspect work

fp2 issue list --json
fp2 issue list --parent <issue-reference|none> --json
fp2 issue show --id <issue-reference> --json

Short references are project-scoped. If one is ambiguous, list issues and retry with more ID characters. issue show returns the issue, its parent and children, the full timeline, its linked pull requests, and the server-authored actions that are legal right now.

Search a project

Search is an API route; there is no CLI subcommand for it, so calling it needs a key in FP2_API_KEY. A credential held in the CLI config under a profile cannot reach this route, and reading the stored key out of the config to get around that is not a supported path.

It is also the one route no representation advertises: nothing hands you a search href, so the URL below is assembled from a project id rather than followed. That is a gap in the affordance surface rather than a pattern to copy — everywhere else, take the href the server gave you.

curl --fail --silent --show-error --get \
--header "Authorization: Bearer $FP2_API_KEY" \
--data-urlencode "q=<term>" \
--data-urlencode "status=todo" \
--data-urlencode "limit=20" \
"https://new.fp.dev/api/v1/projects/<project-id>/search"

Encode the query rather than pasting it into the URL: spaces, &, #, and % are ordinary characters in an issue title, and each one truncates or corrupts a hand-built query string.

It matches issue titles and bodies. Comments are not searched, and scope.fields reports which fields were read rather than leaving a client to imply it searched everything. q is a substring match of up to 200 characters, not a set of words: a two-word query finds only the issues that contain those two words adjacently.

status is a filter, and omitting it searches every status — the opposite of fp2 issue list, which shows open issues until you name one. A search that returns a done issue is behaving correctly.

KeyWhat it is
matched_totalHow many issues matched, whatever the slice contains
scope.searched_totalHow many issues were examined
resultsA bounded slice; limit defaults to 20 and caps at 50
results[].matchThe matched field, a snippet, and ranges within it
facetsMatches per status across the whole match set, not the slice

There is no cursor: results is a slice and the counts are the truth about it. Search rows leave holder null, because a result is a way to find an issue rather than a claim about who is on it.

Record typed timeline events

Every comment is an immutable, attributed timeline event. Type yours:

fp2 issue comment \
--id <issue-reference> \
--body "<what-happened-and-why>" \
--kind <progress|decision|blocker|question> \
--client-id "<stable-retry-id>" \
--json
KindAuthorUse it for
progressagentwhat just happened
decisionagenta choice and its reasoning — the costly thing to lose
blockeragentstuck here; work continues elsewhere
questionagentstuck; a human must answer
feedbackhumanreview of work so far
directionhumanchange of approach
answerhumanresponse to one specific question

Omitting --kind records an untyped legacy note. The server rejects kinds that do not belong to your principal; post only the options the issue representation advertises. There is no edit and no delete — correct a wrong event with another event.

Questions gate closure

Posting a question withholds the issue’s done transition until the sponsoring human resolves it:

fp2 issue comment \
--id <issue-reference> \
--kind answer \
--resolves <question-comment-id> \
--body "<the-answer>" \
--client-id "<stable-retry-id>" \
--json

The human’s representation advertises one answer action per unresolved question with resolves pre-filled; the console renders the same composer inline. Agents cannot answer. Each question accepts exactly one resolving answer. While waiting, keep working other issues and re-read this one to see whether the answer arrived — non-done transitions stay legal.

Transition issue status

Move status with an explicit retry-safe command. Starting work takes the claim, so a start is two commands:

fp2 issue claim --id <issue-reference> --client-id "<stable-retry-id>" --json
fp2 issue transition \
--id <issue-reference> \
--status in_progress \
--client-id "<stable-retry-id>" \
--json

Every other target is one:

fp2 issue transition \
--id <issue-reference> \
--status <todo|review|done|cancelled> \
--client-id "<stable-retry-id>" \
--json

The server enforces:

  • todo → in_progress | cancelled
  • in_progress → todo | review | cancelled
  • review → in_progress | done | cancelled
  • done and cancelled are terminal
  • done additionally requires no open children and no unresolved questions
  • either edge into in_progress additionally requires that you hold the issue

The CLI reads the current issue status and the server uses it as an atomic compare-and-set precondition. A concurrent update returns issue_transition_conflict; an illegal edge returns invalid_issue_transition; a gated close returns issue_children_open or issue_questions_open with the count and the currently legal actions; a start on an issue you do not hold returns issue_claim_required, whose body carries the claim action that fixes it. Retrying the exact command returns the original attributed transition.

Claim an issue

fp2 issue claim --id <issue-reference> --client-id "<stable-retry-id>" --json
fp2 issue release --id <issue-reference> --client-id "<stable-retry-id>" --json

Both commands carry nothing but the retry key. The holder comes from the credential, so there is no way to claim on another principal’s behalf and no flag inviting the attempt.

A claim is a lease, not a lock. holder carries held_until, 30 minutes out, and the writes its holder authors on the issue push it further out — today a comment, a transition, giving it a child, or re-claiming it, which the compare-and-set admits so that an agent which keeps working keeps its claim. Linking a pull request does not extend it. There is no heartbeat endpoint to remember. The expiry is on the wire so that an agent returning from a long tool call can see whether it still holds what it thinks it holds. A lapsed holder keeps being reported until somebody claims the issue; the new claim then overwrites it, and nothing records who was displaced.

Exclusivity is otherwise opt-in, and gating is otherwise conditional on a claim existing:

  • apart from starting work, an issue nobody holds may be transitioned, given children, or given a pull request by anyone, exactly as before claims existed;
  • an issue somebody else holds withholds those actions and blocks them with held_by_other, naming the holder and the expiry;
  • commenting stays open to everyone, because a human answering a question on an issue an agent holds is the case that matters most.

Claiming an issue somebody else holds returns issue_held, which carries the holder and is retryable — a lease expires, so waiting is a real strategy here, unlike every other conflict in this API.

issue_not_held refuses a release for one reason only: somebody else holds a live claim. Releasing an issue nobody holds, or whose lease has lapsed, is a successful no-op, because release means “ensure I am not holding this” and that is already true.

Both verbs require client_id and neither keeps a ledger for it, so neither replays. An immediate re-send after a lost response is safe — a re-claim succeeds and refreshes the lease, and a release of something you do not hold is a no-op. A duplicate that arrives late is a real write: a claim landing after the holder released re-acquires the issue, and a release landing after a later claim drops the newer lease. Re-read the issue instead of sending a stale retry.

fp2 issue link-pr \
--id <issue-reference> \
--url https://github.com/<owner>/<repo>/pull/<number> \
--title "<pull-request-title>" \
--json

github.com only, and fully qualified: nothing in fp2 knows which repository a project belongs to, so the server parses the owner, repository, and number out of the URL rather than inferring any of them. There is no --client-id, because linking one pull request to one issue is idempotent by identity; a re-link corrects a stale title.

No pull-request state is stored — not draft, not ready, not merged. A caller-supplied state word is written once and has no path to update, so it would say draft forever; the state word arrives with a webhook mirror. Links cannot be removed either, and the action says so with reversible: false. Linking never gates a transition.

Recover context

At the start of each work session:

fp2 context --json

The response includes the resolved project, compact open-issue summaries, an optional active issue with its timeline and actions, and executable suggested operations. It is orientation, not a readiness signal; holder is what says whether somebody is on an issue.

Do not invent ready, report, plan, blocker, close, label, criteria, or orchestration commands. They are not shipped. Parents exist only at creation time; there is no re-parenting, and a pull-request link cannot be removed.