npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

light-plan

v0.1.8

Published

A lightweight, file-based issue tracking system. Agile boards as folders and markdown.

Readme

Light Plan

The Light Plan web editor, with the queue panel on the left and the dependency graph of a view on the canvas

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 init

Or run it without installing anything:

npx light-plan init
npx light-plan ui

To get started:

cd my-project
lpm init        # create the board in .lpm/
lpm ui          # open it in your browser

lpm 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 PATH

Requires GNU Make. Without Make:

npm install
npm run build
npm link          # puts `lpm` on your PATH

make 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.md

Folder 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 ~/boards

The 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 everything

lpm 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-4

depends_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 order

A 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 back

The 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 one

Each 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, nested

An 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 period

The 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 it

A 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 it

Completing 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 covers

capacity 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 nobody

Park 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 --remove

Coverage 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 assignee

lpm 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:
  - squad

A 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 start

lpm 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) — claimed

The 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 --skipped instead, and counted under the run, which is what makes this agree with lpm task next and lpm queue agent on a stalled board. lpm queue agent resumes 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 today stays 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 next is withheld here too, and the total says how much, so a short run is never a mystery. --parked includes 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 it

If 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 back

The 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, …), so git log, a git commit with no -m, and lpm open LP-4 — which spawns $VISUAL/$EDITOR and 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 -p and 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 again

inside 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 issued

That 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.md

A 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-12

They 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 — this

lpm 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 period

Every 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 63

Only 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-2

If 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 --fix

which adopts it: allocates an id from the right counter, infers the type from its depth (when that level has only one type), tak