light-plan
v0.1.8
Published
A lightweight, file-based issue tracking system. Agile boards as folders and markdown.
Maintainers
Readme
Light Plan

A lightweight, file-based issue tracker. Your Agile board lives in your repo as folders and markdown files, versioned with git — no server, no database, no account.
Specifically designed for agentic workflows:
- Ships with an MCP server and agent skills.
- Integrated work queue that provides agents with optimized context on the tasks to perform.
- Helps the human in the loop to review work, implement control gates and determine how the development pipeline gets executed.
Install
Requires Node 20+. The package is light-plan and the command it installs is
lpm:
npm install -g light-plan
lpm initOr run it without installing anything:
npx light-plan init
npx light-plan uiTo get started:
cd my-project
lpm init # create the board in .lpm/
lpm ui # open it in your browserlpm init writes the board into .lpm/ in the current folder. It uses the
Scrum template (--template kanban or --template blank for the others) and
takes the issue-id prefix from the folder name (--prefix LP to choose your
own). It also makes .lpm a git repository of its own and adds it to your
project's .gitignore, so the board keeps its own history (--no-git skips
that). On a template with a timeline it also creates a standing Omni Product
Increment holding an Omni Sprint, and every new issue lands in that sprint
until you create a period of your own, so a simple project never has to plan
sprints at all (the omni periods; --no-omni skips them).
A new board starts in queue mode. In queue mode, lpm task next, the agents
and the web app ignore every sprint and increment. They order the work by
priority and by dependencies. lpm init prints the mode, and lpm planning
periods switches the board to sprint planning at any time
(planning by queue; --planning periods
starts the board in that mode).
lpm ui starts a local server on http://localhost:4571 and opens the editor
in your browser. When port 4571 is taken, the server uses the next free port, up
to 4590. What you change there is held as a draft until you press
Push, which writes it to .lpm/. Stop the server with Ctrl-C. To build a
board from the command line instead, see Quick start.
The experimental features — lpm queue agent and
Jira sync — need packages a standard install leaves out.
lpm experimental on installs them. See
Experimental features.
Agent configuration with hcm
light-plan ships agents (a developer and a planner), skills and an MCP server for AI coding hosts. If you manage agent configuration with hcm (harness-config-manager), register the light-plan bundle once, then install it into any project:
npm install -g harness-config-manager # hcm itself, once per machine
lpm hcm init # register the bundle; again after each light-plan upgrade
# then, in each project
hcm install light-plan -t claude-code # developer and planner
hcm install light-plan -t copilot --flavor developer # the developer agent only
hcm update light-plan # after re-running lpm hcm init-t names the harness (hcm targets lists them) and --flavor picks a role
(developer or pm). The MCP server starts as lpm mcp, so agents need lpm
on their PATH: install light-plan globally, not only through npx. Without hcm,
lpm agent installs the same files straight into a
project. The details are in
The same assets as an hcm bundle and
docs/hcm.md.
From a checkout
make setup # installs, builds, and puts `lpm` on your PATHRequires GNU Make. Without Make:
npm install
npm run build
npm link # puts `lpm` on your PATHmake doctor checks your machine before you start, and make on its own lists
every target. See Development.
How a board is laid out
.lpm/
├── config.yml
├── INDEX.md every id, title and document, nested (generated)
├── board/ what gets built
│ └── LP-1/ program — Payments platform
│ ├── _issue.md
│ └── LP-2/ epic — Checkout revamp
│ ├── _issue.md
│ └── LP-3/ feature — Guest flow
│ ├── _issue.md
│ ├── LP-4/ user story — Guest checkout
│ └── LP-5/ bug — Cart total
├── timeline/ when it gets built
│ └── TL-1/ product increment — 2026 H2
│ ├── _period.md
│ ├── TL-2/ sprint 1
│ └── TL-3/ sprint 2
├── team/ who builds it
│ ├── RS-1/ a person — Alice Smith
│ │ └── _resource.md
│ └── RS-4/ a pool anyone can be drawn from — Jr. developer
│ └── _resource.md
├── library/ documents beside the plan: prompts, summaries, PRDs
│ ├── prd/payments.md library:prd/payments
│ └── prompts/code-review.md
└── templates/context/ what a developer is told to do it
├── default.md
└── user_story.mdFolder nesting is the hierarchy. Each folder is one document; its markdown file holds YAML frontmatter (the structured fields) plus a body (the narrative). Everything is plain text, so diffs, blame, branches, and pull requests work exactly as they do for code.
A folder is named after its id and nothing else. Ids are short and never
change, so a path stays quotable, a title can be rewritten without moving
anything, and a board nested five levels deep does not run into the limits git
and Windows put on a path. The titles live in
.lpm/INDEX.md, which every
command that adds, removes, moves or renames a document rewrites for you.
There are three collections. board/ holds issues — what gets built, nested
by scope. timeline/ holds periods — sprints and increments, nested by
duration. team/ holds resources — the people who do the work and the
generic pools work can wait in. They are independent: an issue at any level can
be scheduled into a period at any level via its period: field, and assigned to
any resource via its assignee: field.
templates/context/ holds no documents. It is the one folder the engine never
walks: the layouts lpm instructions renders a working brief
with, so a team decides what a developer is handed when they pick an issue up.
library/ holds the board library: markdown documents
that belong beside the plan and are not a piece of it.
Keeping boards outside the project: the central store
A board can keep its documents in one folder on your computer, outside the project folder. That folder is the central store. You name it once:
cd ~/boards && lpm set central_store # or: lpm set central_store --path ~/boardsThe command writes the key central_store in settings.json of the user
folder (~/.light-plan, or the folder that LPM_HOME names). When another
folder is already the central store, it asks whether to move the boards there
(--move), to abandon the old store and keep its boards in place
(--abandon), or to cancel. When the key is
set, lpm init asks where to create each new board. --central and --local
answer without the question. Without a terminal, lpm init creates a local
board.
~/boards/
└── .lpm_central/
├── shop/ the board of ~/work/shop: config.yml, board/, ...
│ └── project.json { "project_path": "~/work/shop" }, never committed
└── shop-2/ the board of another folder called shop
~/work/shop/
└── .lpm/
└── config.yml store: "~/boards/.lpm_central/shop"The central board folder holds the same files as a .lpm folder, and it is its
own git repository. The project keeps .lpm/config.yml with one key, store,
whose value is the absolute path of the central board folder. Every lpm
command run in the project follows it.
lpm store move --to central and --to local move an existing board, with
its git history. lpm store list lists the central boards.
Each move backs the board up first, into
<central_store>/.lpm_backups/<name>_backup. The move deletes the original
folder only after the copy has the same files, sizes and SHA-256 digests. Each
board has one backup. lpm store backup writes it, and replaces an existing
backup only with --replace. lpm store backups lists the backups.
lpm store restore --yes overwrites the board with its backup.
Each lpm command also records its project in projects.json of the user
folder, so lpm projects lists every board on the computer, local or central.
In the web app, File > "All projects…" shows the same list. Each row has the
buttons Open, Reveal, Move to central (or Move to project) and Back up. The
arrow before the name of a project opens a section with the views of its
board: the name and the description of each view. A click on a view name opens
the board on that view. A move
or a backup runs in the background, and the row shows a progress bar. The tab
Backups lists each backup with its date and its age in days, and Restore
overwrites the board. File ▸ Board configuration… has the same actions for
the open board, on the tab Storage. lpm ui in a folder without a board opens
the "All projects" page when a central store is set. docs/central-store.md
is the full guide, with the assumptions and the known issues.
Quick start
cd my-project
lpm init # creates .lpm/ from the scrum template
# What gets built
lpm new program -t "Payments platform" # -> LP-1
lpm new epic -t "Checkout revamp" -p LP-1 # -> LP-2
lpm new feature -t "Guest flow" -p LP-2 # -> LP-3
lpm new user_story -t "Payment gateway" -p LP-3 --set story_points=5
lpm new user_story -t "Guest checkout" -p LP-3 --set story_points=3
# When it gets built
lpm new increment -t "2026 H2" --starts 2026-07-01 --ends 2026-12-31 # -> TL-1
lpm new sprint -t "Sprint 1" --starts 2026-08-03 --ends 2026-08-14 -p TL-1
# Who builds it
lpm new person -t "Alice Smith" # -> RS-1
lpm new role -t "Jr. software developer" --capacity 3 # -> RS-2, a pool of three
lpm link RS-1 --covers RS-2 # Alice can pick up the pool's work
# Wire it together
lpm link LP-5 --depends-on LP-4 # LP-5 is blocked by LP-4
lpm move LP-4 --period TL-2 # schedule into Sprint 1
lpm move LP-4 --assignee RS-2 # park it in the junior pool
# Reuse what the team already worked out
lpm template list # the registry: reusable pieces of plan
lpm template apply TPL-3 --set name=Payments --under LP-4
# Work it
lpm me "Alice Smith" # this checkout is Alice's
lpm task next # what should I do?
lpm task start # claim it and start the clock
lpm task done
lpm queue simulate --user "Alice Smith" # her whole run, if she worked alone
lpm check # validate everythinglpm new <type> decides what to create from the type name: issue types land in
board/, period types in timeline/, resource types in team/. A name can't
mean two of those — the config rejects that.
A generated issue:
---
id: LP-5
type: user_story
title: Guest checkout
status: backlog
assignee: RS-1
period: TL-2
depends_on:
- LP-4
relates_to: []
related_files:
- docs/prd.md#L120-L164
- src/checkout/session.ts
created: 2026-07-31T15:02:27.576Z
updated: 2026-07-31T15:03:10.114Z
author: Jane Doe <[email protected]>
story_points: 3
priority: medium
labels: []
---
As a **<role>**, I want **<capability>**, so that **<benefit>**.
## Acceptance Criteria
- [ ] **Given** <context> **when** <action> **then** <outcome>Dependencies
Two reserved fields on every issue:
| Field | Meaning |
| --- | --- |
| depends_on | Issues that block this one. Directional, cycle-checked. |
| relates_to | Non-blocking association. No ordering implied. |
lpm link LP-7 --depends-on LP-3
lpm link LP-7 --depends-on LP-3,LP-4 --relates-to LP-9
lpm link LP-7 --depends-on LP-3 --remove
lpm new user_story -t "Checkout" -p LP-3 --depends-on LP-4depends_on is the only edge the engine acts on. There used to be a third,
informed_by, for the research an issue rested on — it gated the queue in
exactly the same way, which made it a second name for one relationship. It is
gone: why an issue is written the way it is belongs in its body and its
related files, and if work cannot start until a question is
answered, that is a dependency. A board still carrying the field is migrated by
lpm check --fix, which merges the ids into depends_on.
Only the forward edge is stored. The inverse — "what does this block?" — is
derived when the board loads and exposed as board.dependents. Storing both
sides would mean two files to keep in sync on every change, and a whole class of
reconciliation bugs for check --fix to chase.
A dependency is inherited by everything inside the issue. A story sits in a
feature, and a feature that waits on another feature waits on it with
everything in it — so you write the edge once, between the two features, and
the queue holds back every story under the second one. You do not have to wire
each story to each other story, and lpm task next will not hand somebody the
stories of a feature whose predecessor has not been started.
A dependency on a container is cleared by the work inside it, not by the
container's own status. Nobody moves a feature through the columns — the stories
under it are what get worked — so LP-7 --depends-on LP-3 is satisfied once
every open piece of work under LP-3 is finished, whatever column LP-3 itself
is sitting in. Closing LP-3 outright still answers for its contents, and
lpm task next names the dependency the way you wrote it (the feature, not the
five stories in it), because that is the document you would open to see where it
stands.
lpm link LP-4 --depends-on LP-3 # feature LP-4 after feature LP-3
lpm task next # ...and no story under LP-4 is offered yet
lpm queue simulate # the whole sequence, in dependency orderA dependency is reflected onto the containers above it, up to the one they share. The inheritance above runs downward — an edge on a feature holds back every story in it. This is the same fact read upward. A story in Guest flow waiting on a story in Sign-up means Guest flow stands behind Sign-up, and the two epics above them stand in the same order, and so on until the container they both sit in, inside which there is nothing left to order. Write the edge between the two pieces of work that actually have it; the levels above it are read off the graph:
lpm link LP-4 --depends-on LP-7
# Linked LP-4 Guest checkout
# depends on + LP-7
# also orders LP-3 Guest flow after LP-6 Sign-up
# also orders LP-2 Checkout after LP-5 Accounts
lpm upstream LP-3 # ...and the feature reads it backThe reflection is never written onto those containers, and both halves of
that are deliberate. A depends_on on Guest flow would be inherited by every
story in it, so one story waiting on one story would hold back a dozen that are
waiting on nothing. And the reflection is not acyclic — two features that each
contain a story waiting on the other are an ordinary plan, and writing that down
would produce a loop lpm link has to refuse. So it is read, never gated: it
changes nothing about what the queue offers, and a loop in it is a fact about
the plan rather than a stall. It shows up in lpm link, in lpm upstream, in
the MCP get_document (rolledUpBlockedBy / rolledUpBlocks) and in the web
side panel, always naming the written dependency it comes from.
lpm link refuses an edge that would close a cycle, so bad state never reaches
the files. lpm check catches cycles introduced by hand-editing or by a merge,
along with references to issues that don't exist, self-references, and
duplicates. It also warns when a done issue is blocked by an unfinished one.
An edge pointing at one of the issue's own ancestors is ignored when work is
ranked rather than treated as a block — it could only stall the work on itself.
Related files
An issue is usually about some code. related_files says which:
lpm new user_story -t "Guest checkout" -p LP-3 \
--related "docs/prd.md#L120-L164" --related src/checkout/session.ts
lpm set LP-5 --related src/checkout/pay.ts # attach another
lpm set LP-5 --unrelated src/checkout/session.ts # detach oneEach entry is a path from the project root, optionally with a line range —
docs/prd.md#L120-L164 points at the paragraphs of the PRD the story was written
from, src/checkout/session.ts at the module it will change. It is plain text
and never checked against the filesystem: an issue naming a file that does
not exist yet is usually the point of the issue, and a board that failed check
because somebody renamed a module would teach people to stop filling this in.
Two things read it. A working brief prints the issue's own files under Files this is about — the first thing to open — and, for each issue this one was sequenced after, the files that work touched. And the web app shows them in the side panel, where they can be edited as one path per line.
An entry that starts with library: names a document of the
board library instead of a file. The brief prints the
body of that document, and the side panel shows the entry as a button that
opens the document.
That second part is what makes the field worth the typing. A story that says
"depends on LP-4" tells you the order; a story that says "depends on LP-4, which
touched src/checkout/session.ts, and I am about to change the same file" tells
you to go and read what LP-4 did first.
The board library
The board library stores markdown documents in .lpm/library/<id>.md. The id
is the path below the folder without .md, for example prd/payments. Use the
library for prompts with instructions, summaries of developments and product
requirement documents.
lpm library new prd/payments -t "Payments PRD" -f ./prd.md # add a document
lpm set LP-4 --related library:prd/payments # reference it from an issue
lpm library # list the documents
lpm library show prd/payments # print the body
lpm library refs prd/payments # the issues that reference it
lpm library mv prd/payments requirements/payments # rename it, and rewrite the references
lpm library rm requirements/payments # delete it
lpm library mkdir archive/2026 # a folder, kept in git while it is empty
lpm library mv prompts archive/prompts # move a folder with everything inside it
lpm library tree # the folders and the documents, nestedAn issue references a document with the related file library:<id>, or with a
link [label](library:<id>) in its body. The working brief of the issue prints
each document that the issue or one of its ancestors references, so
lpm instructions and lpm queue agent hand the document to whoever works the
issue. A registry template can hold the same references, and its placeholders
work in them. The MCP tools list_library, get_library_document,
create_library_document, update_library_document,
move_library_document, delete_library_document, create_library_folder
and delete_library_folder give an agent the same access. In the web app, the
button Library in the top row, or File ▸ Library…, opens the Library
page in its own window. The page shows the folders and the documents as a
tree, moves an item when it is dragged onto a folder, and edits a document
with the editor of the issue window. docs/library.md is the full guide.
Time hierarchy
Periods answer "when does this get built?". They are configured exactly like
issue types — their own hierarchy, their own attributes, their own body
templates — and live under .lpm/timeline with real files carrying their own
metadata:
---
id: TL-2
type: sprint
title: Sprint 1
starts: 2026-08-03
ends: 2026-08-14
created: 2026-07-31T16:52:14.421Z
author: Jane Doe <[email protected]>
goal: Guests can pay without an account
capacity: 34
committed_points: 31
completed_points: null
---
## Sprint Goal
One sentence the team can rally behind.
## Review
...
## Retrospective
...starts and ends are reserved and required. Everything else — goal, capacity,
committed/completed points, PI objectives, retro notes — is configurable
per period type.
lpm new increment -t "2026 H2" --starts 2026-07-01 --ends 2026-12-31
lpm new sprint -t "Sprint 1" --starts 2026-08-03 --ends 2026-08-14 -p TL-1
lpm move LP-4 --period TL-2 # schedule
lpm move LP-4 --period none # unschedule
lpm move TL-3 --parent TL-4 # re-parent a periodThe omni periods
A project that does not plan by the calendar should not have to invent sprints
before it can work. So lpm init seeds one standing period per level of
period_hierarchy — TL-1 Omni Product Increment holding TL-2 Omni Sprint on
the Scrum template, CY-1 Omni Delivery Cycle on Kanban — each running for a
year from the day the board was made, and writes default_period: TL-2 into the
config. Every new issue nobody scheduled lands there, from the CLI, the web app
or an agent alike, so the running sprint holds the whole board.
The catch-all stops the moment you plan for real. As soon as the timeline holds
a period that is not part of the omni chain, new issues arrive unscheduled for
you to place, because ranking them beside your sprint would undo the plan. What
the chain already caught stays there until you move it. When you are done with
it, lpm rm TL-1 deletes the chain and unschedules what is left, and the
default_period line can go.
lpm new user_story -t "Fix login" -p LP-3 # scheduled in TL-2
lpm new user_story -t "Someday" -p LP-3 --period none # left unscheduled
lpm period TL-2 --start-now # a year on: renew itA pull from a tracker never uses the catch-all: an issue the tracker holds unscheduled arrives unscheduled. The full rules are in docs/periods.md.
Planning by queue: lpm planning
Some teams plan in sprints and increments. Others work through the board in order, and do not care which sprint a ticket is in. A board has a planning mode for each way of working, and can switch between the two at any time.
The key planning in .lpm/config.yml stores the mode:
| Value of planning | Mode | Who writes it |
| --- | --- | --- |
| queue | Queue mode: the queue ignores every period | lpm init (the default for a new board), lpm planning queue, the switch in the web queue panel |
| periods, or the key is absent | Periods mode: sprints and increments order the work | lpm init --planning periods, lpm planning periods, the same switch |
A new board starts in queue mode, because most projects start without a sprint
plan. A board that light-plan 0.1.2 or an earlier version created has no
planning key, so that board stays in periods mode until somebody switches it.
A board with no period types is always in queue mode.
lpm planning # which mode the board is in
lpm planning queue # work the board as one continuous queue
lpm planning periods # plan with sprints and increments again (also: pi)In queue mode the whole board reads as one increment holding one sprint
holding everything. lpm task next, lpm queue simulate, lpm queue agent, the
MCP tools and the web queue ignore every period:
- No sprint ranks ahead of another.
- A switched-off period no longer holds its work back.
- No squad owns a sprint, so anybody routed the work can take it.
The order is priority, then column, then the feature already under way, then how much finishing a task unblocks. The web app stops drawing periods too: the Periods and Gantt tabs, the period badges on the canvas, and the period field and column all go away until you switch back.
Switching is non-destructive. No document changes. Every issue keeps its
period:, and every period keeps its dates and its switch. lpm planning
periods removes the key planning and its two comment lines from
.lpm/config.yml, and lpm planning queue adds them again. The mode is board
config, so the team shares it, and a board shared through git commits and
pushes the change. The rules are in
docs/periods.md.
Which period is running
A period runs when today falls inside it, and that is all most boards ever need. But plenty of teams do not plan by date, and every team occasionally needs to reroute people mid-sprint — so there is a switch held over the calendar:
lpm period TL-2 # how it stands right now
lpm period TL-2 --on # run it whatever the dates say
lpm period TL-2 --off # park it
lpm period TL-2 --dates # take the switch off; the calendar decides again
lpm period TL-2 --start-now # move it to start today, keeping how long it runs--on and --off write one optional reserved field, active, on the period
document; --dates removes it. Absent is the normal state — a board that
never touches the switch behaves exactly as it always did.
The two directions are deliberately not symmetric. Switching a period off parks everything nested inside it: "not this quarter" would mean nothing if its sprints kept running. Switching one on speaks for that timebox alone, because a live quarter has never meant all six of its sprints are this week.
What it changes is what gets offered, never what is reachable. Work in a
switched-off period sinks below even unscheduled work in lpm task next, the
MCP next_tasks and the queue — which is what makes the switch a way to steer
a team at short notice. Nothing is hidden, and no document becomes unreadable.
--start-now is the other half: it rewrites dates rather than overriding them.
The period takes today and keeps how long it runs, every period nested inside it
moves by the same number of days so a restarted increment keeps its shape,
whatever else was running is closed yesterday, and the periods above stretch to
reach. In the web UI both live on every box in the Periods tab: a toggle, and a
"Start now" button that says what it is about to change before it changes it.
When a sprint overruns
A period whose end date has passed while work in it is still open is flagged in red, on the CLI and in the Periods tab. There are exactly two honest answers, and both are offered rather than one being chosen for you:
lpm period TL-2 --complete # move the open issues to the board's end state
lpm period TL-2 --carry-over # move them into the next period beside itCompleting records that the team stopped, not that the work happened. Carrying over leaves what was finished where it was delivered — that is the record of the sprint — and moves the rest one period along. Neither invents a period, so carrying work down a run is what makes the last sprint's backlog grow, which is the fact worth seeing. When there is no period after the one being corrected, the command refuses rather than quietly unscheduling the work.
Both act only on issues scheduled directly in the period: an increment answers for its own epics, and the sprints inside it answer for their own stories.
docs/periods.md is the full reference: how the dates and the
switch combine, exactly what each bucket of the work queue holds, and what
restarting or correcting a period moves.
Because the hierarchies are independent, a feature can sit in an increment while
its stories sit in individual sprints — the transversal cut. lpm check
validates that periods have real dates, that ends is not before starts, that
a child period fits inside its parent, that siblings don't overlap, and that
every issue's period points at a period that exists.
It also warns when an issue is scheduled before something it depends on:
warn scheduled in TL-2 (2026-08-03) but depends on LP-4,
scheduled later in TL-3 (2026-08-17)That check is the reason both features earn their keep together.
Periods are opt-in. Drop period_prefix, period_hierarchy and period_types
from the config and the timeline disappears; the blank template ships without
them.
Team and resources
Resources answer "who does the work?". They live under .lpm/team, are
configured exactly like issue and period types, and come in two flavours:
| | What it is | Example | | --- | --- | --- | | named | A person | Alice Smith | | generic | A pool of interchangeable people | "a jr. software developer", "a data scientist, any level" |
Which one a document is comes from its type, not from the document —
a resource type declared generic: true describes a pool:
resource_types:
person:
label: Person
role:
label: Role
generic: true---
id: RS-4
type: role
title: Jr. software developer
capacity: 3
covers: []
created: 2026-07-31T16:12:02.104Z
author: Jane Doe <[email protected]>
discipline: backend
level: junior
skills: [typescript, sql]
---
## What this pool coverscapacity is full-time equivalents: 1 a full-timer, 0.5 someone part-time,
3 a pool of three. 0 means unavailable, and lpm check warns if you assign
work to them anyway.
lpm new person -t "Alice Smith" --set [email protected]
lpm new role -t "Jr. software developer" --capacity 3 --set level=junior
lpm new person -t "Bob Jones" --capacity 0.5
lpm move LP-4 --assignee RS-1 # to a person
lpm move LP-4 --assignee "Jr. soft" # by name, or a unique prefix of one
lpm move LP-4 --assignee none # back to nobodyPark work in a pool and anyone who covers that pool can pick it up:
lpm link RS-1 --covers RS-4 # Alice can work as a junior developer
lpm link RS-1 --covers RS-4 --removeCoverage stores only the forward edge, exactly like depends_on; the inverse is
derived at load time into board.coveredBy. It is one hop and not transitive: a
pool covering a pool does not chain.
lpm team shows who is carrying what:
Roster
RS-1 Alice Smith person 1 FTE open 4 wip 1 done 2 story_points 13 covers RS-4
RS-2 Bob Jones person 0.5 FTE open 1 wip 0 done 0 story_points 3
Pools
RS-4 Jr. software developer pool 3 FTE open 7 wip 0 done 1 story_points 21 covered by RS-1
RS-5 Sr. data engineer pool 1 FTE open 3 wip 0 done 0 story_points 8 nobody covers this
-- (unassigned) open 2 wip 0 done 0 story_points 5
Total 5.5 FTE · 17 open · 1 in progress · 50 story_points · 9.1 per FTE
warn RS-5 (Sr. data engineer) holds open work but nobody covers it
warn 2 open issue(s) have no assigneelpm team --period TL-2 scopes it to one sprint (and its child periods), which
is where over-commitment actually shows up. Only work units are counted, so a
feature does not double-count the stories under it, and a story marked
atomic does not double-count its own sub-tasks.
This is deliberately a load view, not a scheduler: it reports demand against declared capacity, and names the two situations that mean work simply cannot happen — a pool nobody covers, and open work nobody owns. What "too much" means for your team stays your call.
The roster is opt-in the same way the timeline is: drop resource_prefix,
resource_hierarchy and resource_types and team/ disappears.
Squads
A squad is a named sub-team of resources — "Frontend", "Platform", "Data".
When a period is owned by a squad, only that squad's members are offered work
from it. This lets two teams run independent plans inside one board: one
increment per squad, sprints grouped under it, and lpm task next shows each
person their squad's work.
Squads are configured exactly like the rest, and opt-in the same way:
squad_prefix: SP
squad_types:
squad:
label: Squad
attributes: {}
squad_hierarchy:
- squadA squad document lists its members:
---
id: SP-1
type: squad
title: Frontend
members: [RS-1, RS-5, RS-8]
created: 2026-08-03T12:00:00.000Z
author: Alice Smith <[email protected]>
---Assign a squad to a period (lpm set TL-1 --squad SP-1), and the period's work
is gated through the routing. A sprint with no squad inherits from its
increment, the same way the active switch cascades; a sprint can override with
its own.
The squad engine lives in src/core/board/query.ts (effectiveSquad),
board/tasks/ranking.ts (the candidatesFor gate), and board/load.ts
(periodSquadMembers). The web UI manages squads in the Team drawer.
Working as a team member
Tell light-plan who you are, then ask it what to do:
lpm me "Alice Smith" # or `lpm me RS-1`; `lpm me` alone prints it
lpm task next # what to work on, best first
lpm task start # claim the top one: assign it to you, start it
lpm task current # what you have in flight
lpm task done # move it to the board's end state
lpm task prev # what you finished most recently$ lpm task next
Next up for RS-1 Alice Smith
LP-12 Guest checkout User Story · ready TL-2
LP-9 Cart totals User Story · backlog TL-2 · pool RS-4
Claim the first with lpm task startlpm task next offers work units that are assigned to you, or parked in a
pool you cover, and that nothing unfinished is blocking. Add --unassigned to
include work nobody owns. Containers (an epic with children) are never offered —
the work units under them carry the work. Which issues are units is a config
question; see the unit of work.
Work in a period somebody has switched off is not
offered at all — off means "not this one", and ranking it last would only
delay it until the rest of the queue emptied. --parked considers it anyway,
and lpm open/lpm task start <id> reach it by id regardless: this is routing,
not permission.
The order of what remains is: the running or overdue period first, then
unscheduled work, then periods that have not started; within that, the board's
priority_attribute, then the column closest to done, then the part of the
plan that is already under way, then how much each issue unblocks, then age.
That middle step is what keeps a queue from handing out one story from every feature in turn. When nothing a person set by hand separates two stories, the one whose feature somebody is already inside comes first, then the one whose feature is closest to finished, and work in an untouched feature comes last. The question is asked of the outermost level the two do not share, so an epic under way is preferred before its features are ever compared, and two stories in the same feature are never separated by it.
lpm task start [id] assigns the issue to you and moves it to the first status
marked active: true; lpm task done [id] moves it to the first terminal: true
one. Both take an id, and both default to the obvious one — the top
recommendation for start, your single in-flight issue for done. Claiming
someone else's issue needs --force.
Claiming is atomic
lpm task next is a recommendation, and between reading it and acting on it
somebody else may have taken the same issue — another developer in the same
checkout, an agent, a lpm queue agent run. So start is not "write my name on
it". It takes the board's write lock, re-reads the board, checks the issue is
still free as it stands on disk, and only then writes:
$ lpm task start LP-12
error LP-12 is already being worked on by Bob Chen (RS-2)
It is "in_progress" on the board as it stands now, and Alice Smith (RS-1) is asking for it.
Force takes it off somebody who is part-way through it. Take something else instead.The claim is recorded in the issue document itself, not only in its frontmatter:
---
id: LP-12
assignee: RS-1
status: in_progress
---<!-- lpm:activity -->
## Activity
### 2026-08-16T09:14:02.104Z — Alice Smith (RS-1) — claimedThe frontmatter is what withholds the issue from everybody else's queue — work
in an active status is never offered, and the rest is routed by assignee — and
the activity line is how a person reading _issue.md in a file tree, or a diff
in the board's git history, finds out who took it and when. The same is true of
the MCP start_task and of every task lpm queue agent picks up.
A run that loses a claim is not a run that failed. lpm queue agent asks the
queue again and takes the next thing; the issue it lost is left completely
untouched — no flag, no comment, no status change. If it loses several in a row
it stops and says so, rather than spinning against whoever is out-claiming it.
This is one half of what makes a .lpm folder safe to share; the other half is
below.
A parent's status is derived
Nobody works a feature: the stories under it are what get worked, and the feature is a name for them. So a container's status comes from its contents. Finish the last open issue in a feature and the feature is finished too, and its epic with it if that was its last open feature — as far up as it goes. Reopen one and they reopen with it, and adding a new issue inside a finished container reopens it as well.
terminal: true is the whole of what "finished" means here — nothing in
light-plan knows the word "done", it reads the flag. When a board declares more
than one end state, a parent closes into the one its children agree on, and into
the first one declared when they do not.
It happens wherever work is moved — lpm task done, lpm move --status, the
MCP finish_task and update_document, and pushing from the web app — because
it lives in the operation the three of them share. Each container it carries is
printed by the CLI, listed in rolledUp by the MCP tools, and recorded in the
parent's own activity section, so a status nobody typed always says where it
came from.
Two things follow. Anything waiting on a feature is offered the moment its last
story is closed, without anybody remembering to tick the feature off. And a
board where that never happened — frontmatter edited by hand, a merge that took
one side of a status, a board written before this existed — is repaired by
lpm check --fix, which reports every container out of step with its contents
and rolls it up.
Who you are is stored in .lpm/local.json, which lpm init adds to
.lpm/.gitignore: the board is shared, but who is at this keyboard is not.
LPM_USER=RS-2 lpm task next overrides it for one command.
Running the queue forward: lpm queue simulate
lpm task next answers "what now?" one step at a time. lpm queue simulate
answers the other question — if this one person were the only contributor,
what would they work on, and in what order? It takes the top of the queue,
marks it finished in memory, asks again, and keeps going until nothing is left
that they could pick up:
lpm queue simulate # you
lpm queue simulate --user "Alice Smith" # a person on the roster
lpm queue simulate --role "QA engineer" # a pool: anyone working out of it
lpm queue simulate --team # everybody: every open work unit, whoever holds it
lpm queue simulate --user alice --skipped --unassigned --limit 20--team is the sequence the web queue panel numbers its cards by when it shows
everyone's queue.
$ lpm queue simulate --user alice
Queue simulation for RS-1 Alice Smith (person)
id title type effort total
1. LP-12 Guest checkout User Story 3 3 TL-2 · frees LP-14
2. LP-9 Cart totals User Story 2 5 TL-2 · pool RS-4
3. LP-14 Apply a promo code User Story 5 10 TL-2
Total 3 tasks · 10 story_points
2 open issues never reached — see them with lpm queue simulate --skipped--user names a person and --role names a pool (a resource type declared
generic: true). They are separate flags on purpose: simulating a pool as
though it were a person answers a question nobody asked, so the command says
which one you gave it rather than guessing.
It is lpm task next in a loop, not a second opinion about it. Every rule
about what may be picked up — routing, pools, work units, blockers, your
profile's scope, switched-off periods — lives in the engine and reaches the run
only through nextTasks, against a board with the earlier steps marked
finished. A developer stepping through lpm task next by hand gets this
sequence, and runs out where this runs out. That is the point of the command, so
it is also what the tests pin down.
Two consequences worth knowing:
- Work already in progress goes first — unless it is flagged. The queue does
not offer what has already been picked up, so a run that ignored it would
report everything waiting on it as blocked forever. A flag is where that stops:
it says the work has stopped and needs a person, and nobody else is in this
run to clear it, so starting from it would assume away the very thing holding
the queue up. Flagged work is listed under
--skippedinstead, and counted under the run, which is what makes this agree withlpm task nextandlpm queue agenton a stalled board.lpm queue agentresumes in-flight work by the same rule, so the prediction is of the run and not of a different reading of the board. - Nothing is written and no clock moves. The board is untouched, and
todaystays fixed for the whole run, so a period that has not started stays unstarted. This reports an order, never a schedule: light-plan does not know how long a task takes, so it adds up effort and stops there. - Parked sprints are left out and counted. Whatever the switch withholds
from
lpm task nextis withheld here too, and the total says how much, so a short run is never a mystery.--parkedincludes it.
--skipped is the other half of the answer. Nobody else is contributing, so
work held by someone else is never finished and anything waiting on it waits
forever — which is usually the thing you opened the command to find out:
$ lpm queue simulate --user alice --skipped
...
Never reached
LP-20 Settlement report assigned to RS-2 (Bob Jones)
LP-21 Reconcile the ledger waiting on LP-20
LP-22 Refund flow already in progress under RS-4 (Web developer)
LP-23 Import legacy carts assigned to nobody — try --unassigned
LP-24 Audit the payment providers flagged by RS-1 (Alice Smith) — the work has stopped until somebody clears itIf you use a profile, its scope narrows a simulation of you, because that is the queue you are actually offered. Simulating somebody else uses the whole board: you do not hold their profile.
Like lpm team, this reports — it never levels load, assigns anything or writes
a date.
Draining the queue with an agent: lpm queue agent
lpm queue simulate predicts the order; lpm queue agent works it. It takes
the top of the queue, hands the issue's brief (the same one lpm instructions
prints) to an isolated pi coding-agent run with a fresh
context, reads back what the agent reports, and records the outcome the way a
person would — lpm task done on success, a flag on failure — then picks the
next task and repeats.
lpm queue agent --user alice --max-tasks 3 # do the next three, as Alice
lpm queue agent --model anthropic:claude-opus-4-5 --effort high
lpm queue agent --commit task # commit after each finished task
lpm queue agent --file agent.yml # take options from a YAML file
lpm queue agent --dry-run # show the next pick and its brief| Option | Meaning |
| --- | --- |
| --user <id\|name> | Route the queue to this person (default: lpm me) |
| --max-tasks <n> | Stop after this many tasks are picked up |
| --model <spec> | Pi model, as provider:model |
| --effort <level> | Thinking level: off … max |
| --commit <mode> | none (default), task, or parent |
| --unassigned / --parked | Widen the queue, exactly as lpm task next does |
| --timeout <secs> | Per-task wall-clock cap |
| --command-timeout <secs> | Kill any single shell command after this long (default 300) |
| --plain | Log one line per event instead of drawing the live view |
| --file <path> | Read all of the above from YAML (CLI flags win) |
| --dry-run | Pick and brief the next task, but run and write nothing |
It works the same queue lpm task next offers — routing, scope, work units,
blockers and parked periods all still apply — and it finishes every piece of work
under one parent before moving to the next, so a feature lands together. Each run
leaves a comment on the issue and a full JSON log under .lpm/runs/ (tools used
and in what order, timing, tokens and cost when the provider reports them). On
failure the issue is flagged for a human and the run moves on; a flagged task is
never retried.
Before it asks for anything new, a run carries on with what this person is
already holding: a task an earlier run claimed and did not finish — a crash, a
timeout, a run you interrupted — is left in progress, and the queue never offers
work in progress, so nothing else would ever return to it and everything waiting
on it would stay blocked. This is the same rule lpm queue simulate seeds its
prediction with, so the two commands cannot disagree about it.
A flag is where that stops, and because of it a run can stop with the board
apparently full of work: everything left is waiting on issues this person is
already holding, and those are flagged. The run says so rather than just
reporting an empty queue — it lists what they hold, marks which of it is flagged,
and points at the comments that explain why. Clearing the flag
(lpm flag clear <id> -m "...") hands the work back to the next run; finishing
it yourself does the same.
Watching a run
On a terminal the run draws a small pane at the bottom of the screen, on stderr, and rewrites it in place:
LP-14 Guest checkout form validation task 2 · 4m 31s
agent working · bash npm test -- --run src/checkout 7 tools
───────────────────────────────────────────────────────────────────────────
── LP-14 Guest checkout form validation
Adding the validator and a test for it.
❯ bash npm test -- --run src/checkout
✓ src/checkout/validate.test.ts (4 tests)
Test Files 1 passed
↑↓ PgUp/PgDn scroll · Ctrl+C stop 12 lines backThe top two lines say which task, which stage of it (claiming, briefing, agent working, recording, committing), and what the agent is running right now. Under them is the tail of the run: what the agent is saying, the tools it calls and what they print. ↑/↓ and PgUp/PgDn scroll back through it — the view holds its place while new output arrives, and starts following again when you reach the end. Ctrl+C stops the run; the task stays in progress and the next run picks it back up.
The pane is a window, not a transcript. The whole story of a task is on the
issue: a comment when it ends, and the full JSON log under .lpm/runs/.
Redirect the output — or pass --plain — and there is no pane at all: one line
per event, no escape codes, which is what you want in CI or a log file. The
report at the end always goes to stdout, so lpm queue agent > run.txt is a
clean file either way.
A run nobody is watching
The point of the command is that it does not stop, so the agent's shell is set up for a terminal with nobody at it:
- Pagers print and exit and editors return immediately (
PAGER,GIT_PAGER,GIT_EDITOR,EDITOR,VISUAL, …), sogit log, agit commitwith no-m, andlpm open LP-4— which spawns$VISUAL/$EDITORand waits for the window to close — cannot sit there waiting for a keypress. CI=true, which is how a test runner is told to run once instead of starting in watch mode, and how most scaffolders are told not to ask questions.- A command that would open a file in another program is refused —
start,open,xdg-open,code,explorer,less,man,vim,tail -f,Start-Process,git add -pand their like. The agent gets an error saying what was refused and what to do instead, and carries on. - Everything else is killed at
--command-timeout(300s by default). A command the agent gives its own timeout keeps that one; this only fills in a cap where there was none.
This is a guard against a stuck run, not a sandbox: the agent still has a real
shell in your project, and an agent that wants to get around the refusal can. It
is there because one start report.md used to hold a whole queue open until
somebody noticed.
--commit decides what happens to the code the agent wrote in your project:
none leaves it in the working tree for you to review, task commits after each
finished task, and parent commits once all the work under a task's parent is
done. Commits are made in the project repo, not the .lpm board — commit the
board (statuses, comments, run logs) yourself.
lpm queue agent is experimental, so the pi agent it drives is not installed
with light-plan (it needs Node 22.19+). lpm experimental on installs it. To
install it yourself, put it beside light-plan — with -g when light-plan is
installed globally, without it in a project:
lpm experimental on
# or, by hand:
npm install -g @earendil-works/pi-coding-agent @earendil-works/pi-ai
# or, through npx:
npx -p light-plan -p @earendil-works/pi-coding-agent -p @earendil-works/pi-ai lpm queue agent--model takes provider:model for any provider pi supports — Anthropic,
OpenAI, DeepSeek, Google, Groq, Mistral, xAI, and others. Set that provider's API
key in the environment and pi picks it up; omit --model to use pi's default.
export DEEPSEEK_API_KEY=... # or ANTHROPIC_API_KEY, OPENAI_API_KEY, ...
lpm queue agent --model deepseek:deepseek-v4-pro --max-tasks 1(Exact model ids come from the installed pi version's catalog; the pi CLI or its docs list what each provider offers.)
⚠️ The agent runs with real bash and write tools on your project. Running it
unattended is a decision you make; review what it produces, and prefer
--max-tasks and --dry-run while you learn how it behaves on your board.
When the work stops: flags
Sometimes work you have picked up cannot go on. A credential expired, a decision has not been made, a question needs somebody who knows the area. Moving the issue back to the backlog would be a lie — you are holding it — and leaving it in progress is a quieter one, because the board goes on saying it is being worked.
A flag says the third thing:
lpm flag --comment "Sandbox credentials expired; asked ops on #infra"
lpm flag LP-12 --reason help --comment "Need a decision on the retry budget"
lpm flag list # everything stopped, across the board
# the plan owner's side:
lpm flag clear LP-12 --comment "New credentials in the vault; carry on"| Reason | Means |
| --- | --- |
| blocked (default) | something outside this issue has to happen first |
| paused | deliberately set down; the work is fine, the timing is not |
| help | a person is needed — a decision, a review, a pair of eyes |
A flag is not a status. The issue keeps its column and its assignee: it is still yours, still in progress, and the flag says it is not moving. That is a different claim from "nobody has started this", and it is the only one that needs somebody's attention today.
What it does change is what you are offered. lpm task next, lpm task start
and the agent queue all leave flagged work out, whatever column it sits in — so
"blocked, do not start this" on a backlog issue is heard rather than displayed
and ignored. Like a switched-off period, it steers the queue and hides nothing:
lpm open, lpm task start <id> and lpm flag list all still reach it.
A flag carries up the plan. Nobody scrolls to the bottom of an epic to find
out whether anything under it has stopped, so every container above a flagged
issue is marked inside ("Stopped inside") — and the mark comes off by itself
when the last stopped thing inside it starts moving again, whether you cleared
the flag or finished the work. It is the same roll-up a closed story does to its
feature, and it is written and cleared automatically:
lpm flag LP-42 -m "Sandbox credentials expired" # LP-42 blocked; the feature,
# epic and program say "Stopped inside"
lpm flag clear LP-42 -m "New credentials issued" # and all three go quiet againinside is not a reason you can raise — lpm flag --reason inside is refused —
because it is what tells a container the roll-up may clear from one you paused
by hand. A flag you put on a feature yourself is never overwritten and never
cleared for you. lpm flag list shows the issues somebody actually stopped and
counts the containers standing in front of them separately, so the list stays
the short one you can act on.
The comment is required, both ways. A red box nobody can read is a round trip
to ask what it means, which is the trip the flag exists to save — so lpm flag
refuses without one and writes it into the issue's _comments.md in the same
call. Clearing needs one too: whoever raised the flag is the person who reads it.
And every flag change is written into the document itself, comment or not. The flag lands in the frontmatter, and the raise or the clear lands as a dated, attributed line in the issue's activity section:
<!-- lpm:activity -->
## Activity
### 2026-08-16T09:14:02.104Z — Alice Smith (RS-1) — flagged: Blocked
**Flagged: Blocked**
Sandbox credentials expired
### 2026-08-16T14:41:55.881Z — Alice Smith (RS-1) — flag cleared
**Flag cleared** (was: Blocked)
New credentials issuedThat matters most for the flag changes nobody typed a comment for, which on
a working board are the majority: the container that gained inside because a
story four levels down stopped, the same container going quiet again, the flag
that finishing the work answered, the one lpm check --fix wrote to repair a
container that had drifted. None of those writes a comment — _comments.md is
where a person explains a stall, and one derived entry per ancestor per flag
would bury the explanation the flag exists to carry — so the activity line is
the entire record. Without it a node turns red and back again with nothing in
_issue.md, and nothing in the board's git history, saying when or why:
$ git -C .lpm log -p -- board/LP-1/LP-2/LP-3/_issue.md
+flag: inside
+### 2026-08-16T09:14:02.104Z — Alice Smith (RS-1) — flagged: Stopped inside
+
+Work inside this has stopped — see LP-42.Flagged issues are drawn in red on the canvas, a container standing in front of one is drawn in a quieter red, and a collapsed node says how many are stuck inside it — folding a feature must not hide a story that has stopped. Finishing an issue clears any flag on it automatically; the comment trail keeps the history.
Clearing is the plan owner's call, by convention rather than by enforcement. light-plan has no permissions anywhere — a profile routes work, it is not access control — and inventing them here would make that untrue. What the tooling does is record who did it.
Working briefs: the context to actually do it
lpm task next says what to work on. lpm instructions answers the question
straight after it — what do I need to know to start?
lpm instructions LP-12 # markdown on stdout, and nothing else
lpm instructions # the issue you have in progress
lpm instructions LP-12 > brief.mdA story on its own rarely explains itself. So the brief walks up the hierarchy and lays out the title and body of every ancestor — the programme, the epic, the feature — above the issue's own, then adds its breakdown, what it is blocked by, the research it rests on, and its work log:
$ lpm instructions LP-12
# LP-12 — Pay as a guest
You are picking up a user story on the acme board. …
## Epic: Checkout (LP-4)
### Summary
Buy things without an account. …
## Feature: Guest flow (LP-9)
…
## The story: Pay as a guest
### Acceptance Criteria
- [ ] Given a signed-out shopper …Everything about how the brief was made goes to stderr, so the brief itself pipes cleanly into a prompt, a file or a clipboard.
Agents get the same text from the MCP tool get_instructions { id }, which is
what the shipped lpm-developer agent reads before writing any code.
Context templates
The layout is a board-level decision, like the statuses. Layouts live in
.lpm/templates/context/<issue type>.md, with default.md behind them and a
built-in layout behind that — so a board with no templates at all still gets the
ancestors' titles and bodies above the issue's own. lpm init writes starters
for the types your config declares:
lpm instructions --list # which layout each issue type resolves to
lpm instructions --init # write the starters (never overwrites)
lpm instructions --audit # read every layout for risky code
lpm instructions --template ./one-off.md LP-12They are Eta templates — EJS syntax, ordinary JavaScript between the tags:
# <%= issue.id %> — <%= issue.title %>
<% if (epic) { %>
## Epic: <%= epic.title %>
<%= heading(epic.body, 3) %>
<% } %>
## The story
<%= heading(issue.body, 3) %>
<% for (const task of children) { %>
- <%= task.id %> <%= task.title %> — <%= task.status_label %>
<% } %>Every issue type your board declares is a variable, resolving to the nearest
ancestor of that type — so one template can say "this story's epic" without
knowing how deep it sits. heading(n) re-levels a body so it nests under the
heading above it, leaving fenced code alone. Note that an empty array is truthy
in JavaScript: guard a list with .length, an optional document with the name
alone. The full reference, including every value and helper, is in
docs/context-templates.md and in
lpm instructions --help.
Templates are not board truth: they live under .lpm/templates, lpm check does
not know they exist, and no template can make a board invalid.
⚠ Context templates are code
Eta compiles a template into a JavaScript function and runs it. A .lpm
folder arrives over git pull from whoever wrote it, so rendering somebody
else's board can run somebody else's JavaScript, as you, with your filesystem and
your environment variables.
light-plan reads every template before compiling it and refuses the known
escapes — process, require, this, import(), a property reached by a
computed key, and Eta's own file-reading include. The check tokenizes with
Eta's own parser and parses the code with acorn,
so x["cons" + "tructor"] is caught as readily as require:
$ lpm instructions LP-12
error Refusing to render .lpm/templates/context/user_story.md: it can do more than lay out an issue
3:5 danger reaches outside the board (process) — process
4:5 danger `this` is the template engine itself, not the issue — thislpm instructions --audit runs that check over the whole board and exits 1 on a
finding, so it drops into CI beside lpm check. --unsafe renders anyway, for a
template you wrote and meant; the MCP tool has no equivalent, because letting an
agent opt out is the whole hole.
This refuses the known escapes; it cannot make an untrusted template safe.
Read the templates that arrive with a board you did not write, the way you would
read a postinstall script. Use at your own risk.
Profiles: giving one developer one part of the board
A profile is a small YAML file you hand a developer — or an agent. It says who they are and which part of the board they should be offered:
# alice.yml
user: Alice Smith
scope:
under: [LP-2] # only work at or below these documents
exclude: [LP-9] # never these, nor anything below them
types: [user_story] # only these issue types
periods: [TL-2] # only work scheduled here, or in a child periodEvery key is optional, and every one of them narrows: under on its own scopes
someone to an epic, exclude on its own keeps them out of one, and the two
together read as "this programme, but not that feature". A period folds its
child periods in, so naming an increment includes its sprints. Write a starter
file and start using it in one command:
lpm profile --init ~/.lpm/alice.yml --user "Alice Smith"
lpm profile ./profiles/alice.yml # or point at one you were given
lpm profile # what is in force, resolved against this board
lpm profile --clear$ lpm profile
Profile /home/alice/.lpm/alice.yml
.lpm/local.json
user Alice Smith
scope under LP-2 · not LP-9
offers 14 issues of 63Only the path is remembered, in .lpm/local.json beside the current user and
git-ignored with it — the profile belongs to the developer, not to the board.
LPM_PROFILE=./profiles/bob.yml lpm task next points at a different one for one
shell, and lpm mcp --profile <file> does the same for one agent session.
Scope decides what the board offers you, never what is reachable. It
narrows lpm task next and lpm task start with no id, and the MCP tools
next_tasks and list_documents. It does not touch lpm open, lpm set,
get_document, or lpm task current — work you have already picked up stays
yours even if the scope it came from moves, and a dependency you cannot read is
worse than a recommendation you should ignore.
So this is routing, not access control: the board is a folder of markdown that whoever holds the profile can read, and a profile decides what is handed to them. Use it to keep a team of ten out of each other's epics, or to give five agents five slices of one plan. Do not use it to keep a secret.
Whatever is in force is printed with the work it filters, so a short list is never a mystery:
$ lpm task next
Next up for RS-1 Alice Smith
scope under LP-2 · not LP-9
LP-12 Guest checkout User Story · ready TL-2If the file names something this board does not have, that is reported and the
rest still applies — except that a stale under offers nothing rather than
quietly widening to everything. A profile that does not parse at all is
reported too, and light-plan carries on unscoped. Unknown keys are an error:
excludes: would otherwise silently hand someone the whole board.
Where identity is concerned the most specific answer wins: LPM_USER, then the
profile's user:, then lpm me. lpm me says which one is talking.
docs/profiles.md is the full reference: every key, how the
file is found, what each surface does and does not filter, and how to set up a
team or a swarm of agents.
Working by hand
The CLI is a convenience, not a gatekeeper. You can create a folder and an
_issue.md (or _period.md, or _resource.md) yourself — even one containing
nothing but a heading — and then run:
lpm check --fixwhich adopts it: allocates an id from the right counter, infers the type from its depth (when that level has only one type), tak
