@floh-solutions/plan-to-board
v0.2.0
Published
Turn an implementation-plan .md into an Azure DevOps Epic -> Issue -> Task tree, and write the resulting ids back into the file so re-running updates instead of duplicating.
Readme
@floh-solutions/plan-to-board
An implementation plan becomes an Azure DevOps Epic → Issue → Task tree, and
the resulting work item ids go back into the plan file.
That last part is the whole point. A tool that can only be run once is a demo: the second run duplicates the board. With the ids recorded in the file's front matter, the second run updates — and the plan file and the board stay one thing rather than two that drift.
npm i -g @floh-solutions/plan-to-board
export ADO_ORG=your-org ADO_PROJECT=YourProject ADO_PAT=…
plan-to-board impl-plan.md # dry run: print the tree, write nothing
plan-to-board impl-plan.md --live # ask, then apply, then record the ids
plan-to-board impl-plan.md --live # …and again: a no-opIt also ships as pharos plan inside
@floh-solutions/pharos-cli,
which is the same code behind one command with the rest of the Azure DevOps
surface next to it.
The five steps:
| | | |
|---|---|---|
| 1 | read impl-plan.md | parse.ts |
| 2 | emit a structured intermediate to approve | render.ts — the dry run, or --json |
| 3 | create Epic → Issue → Task with parent links and iteration paths | sync.ts, apply.ts |
| 4 | publish the plan to the project wiki, link it from the Epic | wiki.ts — behind a switch, see below |
| 5 | write the ids back as front matter | frontmatter.ts |
The plan file
---
project: Contoso
iterationPath: Contoso\Iteration\Sprint 1 # defaults for every node below
assignedTo: [email protected]
---
# The initiative <- the Epic. Exactly one.
key: the-initiative <- metadata, only at the top of a section
Prose here becomes the Epic's description.
## A phase <- an Issue
owner: [email protected]
### A task <- a Task
…or, if a phase has no `###` headings, its checkboxes are its tasks:
- [ ] A task
- key: stable-name
- files: packages/thing/src/a.ts
- depends: some-other-task
- [x] One that is already doneThree levels. A task cannot have subtasks; there is deliberately no fourth level to design around.
Epic, Issue and Task above are the plan file's own words, not the names
this will create. Every run reads the project and maps each level to the type
that plays its role there — Microsoft.EpicCategory, then
Microsoft.RequirementCategory, then Microsoft.TaskCategory:
| plan | Basic | Agile |
|---|---|---|
| the initiative | Epic | Epic |
| a phase | Issue | User Story |
| a task | Task | Task |
So one plan file produces the right tree on either template. It used to write
those three names literally, which was correct on Basic and failed on the first
create anywhere else: on Agile an Issue is an impediment tracked off the
backlog, not a requirement. The tree a run prints names what it resolved, and
says assumed when it could not read the project.
Either ### headings or checkboxes, per phase. A phase with ### headings
uses those and leaves its checkboxes as prose — so acceptance criteria under a
task stay acceptance criteria. A phase with none uses its outermost checkboxes.
Metadata is only read at the top of a section (or directly under a
checkbox), and only for keys it knows: key, owner/assigned to, state,
iteration/sprint, area, priority, files, depends on. The first line
that isn't one of those ends the block and starts the description, so a
paragraph opening Note: … is prose, not a field. **Owner:** x works too.
- [ ] is To Do, - [x] is Done, - [~]/- [/] is Doing. An explicit
state: beats the box. Headings and checkboxes inside fenced code blocks are
left alone.
Those three are mapped per type the same way the levels are, and by the state's
category rather than by its name: To Do becomes wherever a create of that
type lands (its first state — which matters, because Azure DevOps refuses a
create naming any other), Doing its first InProgress state, Done its first
Completed one. On Agile that is New, Active, Closed. Completed beats
Removed deliberately: a - [x] says the work was finished, never that it was
cancelled.
Keys are identity
A node's key is how the next run finds the work item it made last time.
Without an explicit one it is slugged from the title — so renaming a node
without a key: reads as a delete plus a create. Give anything you might
retitle a key: and renaming becomes a title update.
Step 5: the board: block
After a live run the tool adds, and thereafter maintains, exactly one block:
board:
organization: contoso
project: Contoso
syncedAt: "2026-08-03T10:52:11.402Z"
epic:
id: 120
key: p2b-smoke
hash: 6e25cf957642
phases:
- id: 121
key: round-trip
hash: b326aeafc741
tasks:
- id: 122
key: first
hash: cc657588319cEverything else in the front matter — key order, comments, quoting, other tools' keys — is preserved byte for byte.
organization/project— the ids only mean anything in the project that issued them, so pointing the file at another one stops with an error rather than rewriting unrelated work items.--forceoverrides.hashis a digest of the description last written. See below.orphans:holds items the plan no longer describes. They are kept, not dropped: forgetting an id strands a real work item, deleting it is not this tool's call, and if the section comes back its id comes back with it.
The file is written after every single create, not once at the end. A run that dies halfway has already recorded what it made, so the next run finishes the job. This is not hypothetical — the first live run of this package died on its fourth item, and the re-run created exactly that one.
Two pending grants, two switches, one file
grants.ts owns both, and nothing else reads them. Both default off, and
every run prints one line per denied grant saying what it did instead — a
silently skipped step is indistinguishable from one that was never built.
| grant | fails as | fallback | switch |
|---|---|---|---|
| Create tag definition | TF401289 | title prefix + Area Path | --tags, PLAN_TO_BOARD_TAGS=1 |
| Contribute on the project wiki | see below | the plan file stays local | --wiki, PLAN_TO_BOARD_WIKI=1 |
Neither workaround is scattered: labels.ts is the only thing that prefixes a
title, and flipping --tags on makes the same update path strip the prefix
back off and tag instead — no migration script.
The wiki is the designed home for plans, not a nice-to-have. Per project, inside Azure DevOps, readable by anyone with board access, git-backed, linkable from the Epic it generated. Once this is pointed at someone else's project, their plan cannot live in this repo. The publish step is built, tested, and switched off — the local file is the fallback, not the destination.
The wiki is blocked by two different things
Verified live 2026-08-03, and they have different fixes:
- The PAT lacks the wiki scope. The same token reads a work item with
200andGET _apis/wiki/wikiswith401. That is the token, not the account — regenerate it withvso.wiki_writealongsidevso.work_write(PLAN.md §5 always said it should carry both). No admin needed. - The account lacks Contribute on the wiki repo, which is the grant
pending with the project admins (PLAN.md §9). It surfaces as
WikiPageOperationFailedExceptionon a write.
--wiki reports whichever one it hits, by name, rather than a bare 401.
What the plan owns, and what the board owns
The plan file owns titles, descriptions, area path, iteration path, priority
and tags. The board owns System.State and System.AssignedTo once an
item exists — those are what a person changes while working, and pushing a
stale - [ ] over someone's "Doing" every run is how a tool gets turned off.
Drift is always reported:
= Task #122 [SMOKETEST] First task
board has state Doing, the plan says To Do — left alone--push-state and --push-assignee make the plan win instead.
Findings this package encodes
Each cost a live call to learn.
System.IterationPathis not the classification node path. The node is\Contoso\Iteration\Sprint 1; the field holdsContoso\Sprint 1. PLAN.md §8 records the node form, so passing it through unchanged is a 400 that reads like the iteration does not exist.normalizeClassificationPathconverts either form, andSprint 1alone, into the field form.- A create may only land in the initial state. A create patch carrying
System.State: Doneis refused with "the value 'Done' … is not in the list of supported values" — thoughDoneis perfectly legal on the type. So a- [x]task is created inTo Doand transitioned by a second call. - The description cannot be compared against what the server returns. The
read path HTML-encodes long text and runs a sanitizer that drops
tag-shaped runs: a description containing
Array<String>reads back asArray. Comparing text would rewrite it on every run forever, so the comparison is against the recordedhashinstead. Confirmed live — the smoke-test Epic contains exactly that, and its third run was still a no-op. workitemsbatchwitherrorPolicy: omitreturnsnullin place, so an unreadable id arrives as a hole in the array rather than a shorter array.- A rev mismatch is not proof of a collision. A comment alone bumps
System.Rev, so a rejected patch is diffed field by field before anyone is told they have a conflict.
Every update is guarded by the test op on /rev (PLAN.md §5), so a teammate
editing mid-run gets a rejected patch instead of losing their edit. A conflict
is reported and the run carries on with the rest of the tree.
Usage
plan-to-board <impl-plan.md> [options]
--live actually write. Without it this is a dry run.
--yes, -y skip the confirmation prompt (required off a terminal)
--json print the structured intermediate as JSON and exit
--offline render the plan without checking the board (no PAT needed)
--tags / --wiki turn on a step that is waiting on a grant (see above)
--wiki-path <path> wiki page path (default /Plans/<epic key>)
--wiki-id <id> target a specific wiki (default: the project wiki)
--prefix <text> title prefix for generated items (default [Pharos])
--org / --project Azure DevOps organization and project
--area / --iteration / --assign defaults for nodes that set none
--push-state / --push-assignee let the plan overwrite the board
--no-write-back do not touch the plan file
--force proceed even if the file was synced to another projectAuth is ADO_PAT / ADO_ORG / ADO_PROJECT, the same as
@floh-solutions/ado-core. A live run asks before writing unless --yes is given,
and refuses to write unconfirmed when there is no terminal to ask at.
Tests
pnpm test # 66 tests against a mocked fetch. Offline. This is the gate.
pnpm typecheckThe suite covers the round trip end to end: the first run creates and records, the second reads the batch and issues no writes at all.
Verified live against contoso/Contoso on 2026-08-03 — work items
#120–#123, prefixed [SMOKETEST] and assigned to [email protected],
queued for deletion with the rest under board todo #527. Four runs: create,
recover-from-a-crash, no-op, and a retitle that updated exactly one item.
