Skip to content
This text was generated by an LLM (how cgwalters uses LLMs). cgwalters has not reviewed it yet.

Epic plans

An epic plan is a coordinator's proposed changes to an epic's sub-issues, written as a comment on the epic (slice 2 of tracker#572). Like a job plan it is a proposal: agent-board checks it against bounds from the caller's configuration, and only those bounds limit what it may change. epic plan --dry-run prints the GitHub mutations a plan would make. epic apply ISSUE --config FILE previews by default; --apply acts with the configured bot's token. Ordering is expressed only by sub-issue Priority. Apply and its preview require positive evidence of the parent's epic label; apply checks it again after claiming. This prevents the planner from starting decompositions on ordinary issues or its own new children. Anyone with triage access to the repository can add the label, and so can the bot; the plan's bounds are the limit. With label_by_operator in the configuration, the newest epic labeling must also be the configured operator's. A closed epic refuses every plan, in the dry run, the preview and apply (again after claiming): closing it is the operator's decision that the decomposition is finished or dropped, so a plan written before may not add to or change it. Only where the configuration allows it may a plan close the epic itself (close_epic).

Format

One fenced block with the info string agent-board-epic-plan, at the start of a line, holding one JSON object:

```agent-board-epic-plan
{
"schema": "agent-board/epic-plan/v1",
"request": "coordinator-123",
"epic": "cgwalters-forge/tracker#572",
"actions": [
{"type": "create_sub_issue", "repo": "cgwalters-forge/agent-board",
"title": "epic apply", "body": "...",
"plan": {"schema": "agent-board/job-plan/v1", "kind": "implement",
"runner": "hosted", "brief": "..."}},
{"type": "set_priority", "new": 1, "value": "P1"},
{"type": "close_sub_issue", "issue": "cgwalters-forge/tracker#573", "reason": "..."},
{"type": "rescope_sub_issue", "issue": "cgwalters-forge/agent-board#63",
"body": "...", "reason": "..."}
]
}
```

request is optional correlation metadata: a string of 1 to 128 ASCII letters, digits or hyphens ([A-Za-z0-9-]). It grants no authority and is not a replay key. Plans without it remain valid. See the schema for the structural format; runtime checks also enforce the bounds and relationships below.

epic must be the issue the plan is on. Each action is one of:

typeFieldsMutations
create_sub_issuerepo, title, body; optional labels, plan, stacks_onCreate the issue, link it to the epic, enroll it in the project with its Priority, and post plan as a job plan
close_sub_issueissue, reasonComment the reason, close as not planned
rescope_sub_issueissue, body, reasonReplace the body, comment the reason
set_priorityissue or new, valueSet the board's Priority
close_epicreason; optional moved_toComment the reason (and where the work moved) on the epic, close it as completed

close_epic lets a plan end an epic with nothing left to do, which would otherwise be replanned empty on every pass. It must be the plan's last action, the plan may not create a sub-issue, and every sub-issue must be closed, before or by an earlier close_sub_issue in the plan, unless moved_to (OWNER/REPO#N, in a configured repository, neither the epic nor one of its sub-issues) names the issue now tracking what is left open. Apply and its preview refuse it unless the configuration sets close_epic; the dry run, which has no configuration, shows it.

issue is OWNER/REPO#N; new is the 1-based position of an earlier create_sub_issue in actions. A job plan in plan has every field of a job plan except repo and item, which are the new issue's. In the dry run's output the new issue's number is NEW-k, and project fields are named by field and option, which apply resolves to node IDs.

stacks_on names the slice a new one builds on: k, an earlier create_sub_issue with an implement plan, or OWNER/REPO#N, an open sub-issue, in the same repository either way. It needs an implement plan, which gets it as its stacks_on (apply fills in a new slice's number), so its run starts from the lower slice's pull request branch and its pull request targets that branch. Slices that change the same files should stack, in the order they can land; independent slices name no stacks_on and stay on the default branch. The planning brief that asks for these plans is the caller's, and should say so.

Refusals

The plan is refused if any action is, and every refused action is named with its position and reason. Only the epic the plan is on is protected as an epic: a sub-issue that is itself an epic is treated as any sub-issue. Refused are:

  • an issue that is neither new nor a current sub-issue of the epic (read from GitHub at check time), and a closed sub-issue, which a plan may not revive (a close_sub_issue of one is not refused but satisfied: it makes no mutations, and the dry run and status list it as satisfied, so a plan that went stale when its pull request merged still applies);
  • a repository not given with --allow-repo;
  • any change to the epic itself, whose text, priority and labels are the operator's, and an epic label on a new issue, which would let the planner start epics of its own;
  • a Priority not given with --priority-option;
  • unknown fields and action types, anywhere, including in a job plan, and a stacks_on in a job plan rather than on its action;
  • a stacks_on without an implement plan, or naming anything but an earlier new slice with one or an open sub-issue, of the same repository;
  • two actions of one type on one issue, or any other action on an issue the plan closes, a new slice that stacks on it included: its run would start unstacked once the pull request it was written to build on is abandoned;
  • a close_epic that is not the last action, follows a create_sub_issue, or, without moved_to, leaves a sub-issue open or closes an epic that never had one;
  • a reason holding an ```agent-board fence or a <!-- marker: it is posted as a comment by the applier, which may also be a plan writer, where it would count as a plan or a dispatch record;
  • a body holding an ```agent-board-plan fence: the pass's bodyPlans would take it as the issue's plan, unchecked against the issue it names;
  • more than 20 actions, titles over 256 characters, bodies over 60,000 and reasons over 4,000, and empty text.

Who may write one

As for job plans: only the operator's (--operator) and the planner's (--planner) comments count, the newest written or edited wins, and a newest plan that is invalid or last edited by anyone else stops the check rather than falling back to an older one. See job plans. For apply, the planner is the configuration's planner; without one it is the bot, so every comment the bot writes is then a trusted plan writer's. Name a separate planner when it has its own login. The bot still posts the job plans of the sub-issues it creates, so dispatching those needs the bot as their --planner.

Dry run

Terminal window
agent-board epic plan "$EPIC" --dry-run --operator "$OPERATOR" --planner "$PLANNER" \
--allow-repo OWNER/REPO ... --priority-option P0 ...

prints one JSON object a line per mutation, each tagged with its action's position, preceded by a {"request":"..."} metadata object when supplied (even for an empty plan). Apply's preview uses the same metadata object; successful apply and already-claimed output print Request: ... when supplied. The dry run names the plan's comment on stderr. It needs a token that can read the epic. The caller passes --allow-repo and --priority-option from its own trusted epic configuration (the repositories and priorities names of the Apply --config file; tracker's agent-board.json is not one), never from a plan or a dispatcher's inputs.

Apply

Terminal window
agent-board epic apply "$EPIC" --config epic-config.json --dry-run
agent-board epic apply "$EPIC" --config epic-config.json --apply
agent-board epic apply "$EPIC" --config epic-config.json --apply \
--expect-request "$REQUEST" --expect-comment "$COMMENT_ID" \
--expect-updated-at "$UPDATED_AT"

To avoid applying a replacement or edited plan after checking it, callers can optionally supply these preconditions. Selection is unchanged: the newest trusted writer's plan must match every supplied expectation. --expect-request requires that exact request string (a missing field refuses); --expect-comment requires the numeric comment ID; --expect-updated-at requires the RFC 3339 last-edit time, or creation time for an unedited comment, equal as an instant without truncation. Preview checks them too. A mismatch reports epic plan precondition failed and exits 3, distinct from an invalid plan (1) and authentication required (4). With no flags, behavior is unchanged.

Apply checks at selection and performs one additional bounded comment read immediately before its first write (the claim), then checks again after claiming. A mismatch before the claim writes nothing; a change after the claim retains it for inspection. These checks do not make GitHub reads and writes transactional; callers must still serialize passes. Preconditions do not change replay keys.

The trusted configuration is a file from the caller repository's protected default-branch checkout. Do not populate it from workflow inputs, a plan, or a dispatch branch. Its JSON object has these fields (unknown fields are refused):

{
"operator": "OPERATOR_LOGIN",
"planner": "PLANNER_LOGIN",
"bot": "BOT_LOGIN",
"repositories": ["OWNER/REPO"],
"project": "PROJECT_NODE_ID",
"priority_field": "Priority",
"priority_field_id": "FIELD_NODE_ID",
"priorities": {"P0": "OPTION_ID_0", "P1": "OPTION_ID_1"},
"default_priority": "P1",
"label_by_operator": true,
"close_epic": true
}

close_epic is optional and off by default. Off, a plan with a close_epic action is refused and closing an epic stays the operator's alone; on, a plan writer may close one whose sub-issues are all closed, or whose remaining work it says has moved (moved_to is its word, not checked further).

label_by_operator is optional and off by default. On, the preview and apply (before and after claiming) read the epic's last 100 labelings and refuse unless the newest epic one was added by a user whose login is operator. An app's labeling never counts, and a newest epic labeling older than the last 100 labelings refuses rather than falling back to an older one. This stops someone with only triage access, or the bot, from starting a decomposition that the bot then carries out; operator and bot must then differ. GitHub names a labeling's label as it is called now, so renaming a label the operator once added to epic (which takes write access) would also pass. It covers epic apply only, not the supervisor's implement gate (supervisor).

planner is optional and defaults to bot. IDs and names are configuration, not planner-controlled values. A new sub-issue gets its Priority once, as it is created: the plan's set_priority of it, else default_priority; not the default and then a second change. The configured field must be a single-select field. The bot needs issue write and project write access. Neither command dispatches a job or changes Status.

To prevent duplicate creation after a partial or ambiguous write, apply posts its receipt, which claims the plan COMMENT-ID@CHANGE-TIME, before any mutation and reads it back, as job dispatch does. The oldest claim wins; only operator/bot-authored claims count, not a separate planner's. Callers must serialize apply passes, since GitHub comment reads are not a transactional lock. A claimed plan is a no-op on re-run. The claim, not validation, is what stops a replay: a close the plan made is satisfied when it is checked again. A foreign edit to the plan still refuses it; accepted edits identify a new plan.

Apply stops on the first failed request, records each confirmed write (including partial actions), and retains the claim. No rollback or automatic retry occurs. Inspect a failed or interrupted run and edit/replace the plan to continue. Failure to update the receipt is also an error, not permission to retry. Receipt deletion by a repository writer can enable replay, as for dispatch.

The receipt is one comment on the epic, readable first: what the apply did, one line per sub-issue with its final state, then one fenced agent-board-epic-apply block with the machine detail. It is updated after each new sub-issue is created, so an apply cut short still names what it made, and once more when the apply ends:

<!-- agent-board-epic-apply: 1@2026-10-23T13:34:49Z -->
Applied [this epic plan](https://github.com/OWNER/REPO/issues/7#issuecomment-1) at 2026-10-23T13:41:30Z.
- Created [agentic-job#715](https://github.com/OWNER/agentic-job/issues/715) (P0)
- Closed #611 as not planned
```agent-board-epic-apply
{
"schema": "agent-board/epic-apply/v1",
"plan": "1@2026-10-23T13:34:49Z",
"outcome": "applied",
"actions": [
{"action": 1, "type": "create_sub_issue", "issue": "OWNER/agentic-job#715",
"priority": "P0", "writes": ["create", "link", "priority", "comment"]},
{"action": 2, "type": "set_priority", "issue": "OWNER/agentic-job#715",
"priority": "P0", "writes": ["folded"]},
{"action": 3, "type": "close_sub_issue", "issue": "OWNER/REPO#611",
"writes": ["comment", "patch"]}
],
"fingerprint": [["owner/agentic-job#715", "OPEN"], ["owner/repo#611", "CLOSED"]]
}
```

outcome is claimed (under way, or cut short before it could say), applied, stopped (a request failed) or recovered (cut short, and every action's effect later proved by agent-board pass from the identities the receipt confirmed). fingerprint is the sorted list of [lowercase OWNER/REPO#N, OPEN-or-CLOSED] pairs for all sub-issues once the apply ended (absent where that read failed): membership and state, not body or Priority. writes names the requests confirmed; folded is a new sub-issue's Priority, set by its create's own write. A sub-issue the apply created is named only by the identity its create returned, never found by its title. The hidden first line is the older receipts' claim, kept so that an earlier build sees it too. Older receipts, that line with a separate <!-- agent-board-epic-fingerprint: JSON --> comment, are still read as claims and receipts. A comment by the operator or the bot that looks like a receipt but cannot be read, or that anyone else edited, claims every plan: apply refuses until a person inspects it.