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:
type | Fields | Mutations |
|---|---|---|
create_sub_issue | repo, title, body; optional labels, plan, stacks_on | Create the issue, link it to the epic, enroll it in the project with its Priority, and post plan as a job plan |
close_sub_issue | issue, reason | Comment the reason, close as not planned |
rescope_sub_issue | issue, body, reason | Replace the body, comment the reason |
set_priority | issue or new, value | Set the board's Priority |
close_epic | reason; optional moved_to | Comment 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_issueof one is not refused but satisfied: it makes no mutations, and the dry run and status list it assatisfied, 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
epiclabel 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_onin a job plan rather than on its action; - a
stacks_onwithout 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_epicthat is not the last action, follows acreate_sub_issue, or, withoutmoved_to, leaves a sub-issue open or closes an epic that never had one; - a
reasonholding an```agent-boardfence 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
bodyholding an```agent-board-planfence: the pass'sbodyPlanswould 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
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
agent-board epic apply "$EPIC" --config epic-config.json --dry-runagent-board epic apply "$EPIC" --config epic-config.json --applyagent-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.