Design note: workstreams¶
Status: built (2026-10-21): the workstream kind, the part_of relation, resolution, ws,
assign, summary, lint, list --ws, and the console's list filter and stats rollup. Tracked
in ROADMAP.md.
Why¶
The ledger groups work by where it came from (reviews, my PRs, mentions, outreach, tasks) and by lineage (what came from what). Neither answers "what did I do on X this week?". A weekly stand-up, stats per effort, the knowledge graph and learning all need to know which effort an item belongs to, without guessing and without counting an item twice.
A workstream is a time-bound effort I drive, such as "Widget search" in the acme world. It is not:
- a Jira epic. An epic is an external container that someone else may own and that outlives or undercuts my effort. A workstream may name its epic, and the epic then assigns work to it.
- a knowledge-graph product area or feature. Those are long-lived capabilities. A workstream is part of an area for a while and then ends.
- lineage. Lineage says how items arose (
spawned_from,produces). A workstream says which effort they serve. Lineage is how a workstream spreads from a tagged root to everything that came from it.
Research: how trackers group work¶
| Tool | Containment | Membership | Depth | Rollups | Status reporting |
|---|---|---|---|---|---|
| Jira | Epic > story > sub-task; Plans add levels above epic (e.g. initiative) | one parent per issue (it replaced Epic Link and Parent Link); labels and components for the rest |
fixed levels; custom levels only above epic | Plans roll up dates and estimates for planning only, never written back | none built in (Atlassian Goals and Projects carry updates) |
| Linear | Initiatives > projects > issues; project milestones; sub-issues | an issue is in one project at a time; labels for the rest | sub-initiatives up to five levels (Enterprise) | a parent initiative's progress includes its sub-initiatives' projects | project and initiative updates (on track, at risk, off track) on a reminder schedule, with automatic diffs |
| GitHub | Projects, milestones, sub-issues | an issue can be in several Projects, but has one parent and one milestone | sub-issues up to 8 levels, 100 per parent | sub-issue progress shown in Projects; a milestone's percent complete | Project status updates (on track, at risk) |
| Asana | Goals > portfolios > projects > tasks | tasks and projects multi-home in several places | portfolios nest | goal progress from sub-goals or from projects and portfolios | status updates on goals, portfolios and projects |
| Azure DevOps | Epic > feature > story > task | "a work item can have only one Parent"; the parent links form a tree without cycles | fixed portfolio levels | rollup columns computed in the view, per user | none built in |
| Shape Up | Bets on pitches, for a six-week cycle and a fixed appetite | one bet per team per cycle | none: nothing nests | none | the hill chart |
| Atlassian Goals | Goals with sub-goals | a sub-goal works like a goal | nesting | status does not roll up from sub-goals automatically (2024) | monthly updates |
Sources:
- Jira: custom hierarchy levels, roll-up values, the parent field replacing Epic Link and Parent Link.
- Linear: projects, sub-initiatives, initiative and project updates.
- GitHub: sub-issues, sub-issues REST API, tasklists retired, Projects, milestones.
- Asana: hierarchy, object hierarchy, goals.
- Azure DevOps: link types, organizing the backlog, rollup.
- Shape Up: bets, appetite.
- Atlassian Goals: what a goal is, no automatic status rollup.
Not verified from official pages: Asana's rollup through nested portfolios, and whether a Linear project can sit under more than one initiative.
What this means here:
- One primary container per item, plus loose tags. Jira, Linear, GitHub sub-issues and Azure
DevOps all give an item exactly one parent and put cross-cutting membership in labels,
components or Projects. Asana's multi-homing is the exception, and it is what makes rollups
ambiguous. Here: one
part_ofper item, andrelates_tofor any second tie, which counts nowhere. - Nesting is allowed but shallow. Every tool that nests caps the depth, and Shape Up does not nest at all. One person's handful of efforts needs at most an umbrella over a few workstreams.
- Rollups are computed, never stored. Jira Plans does not write rolled-up values back, and Azure DevOps computes rollups in the view. Here, membership, inheritance and counts are all computed on read.
- Status reporting is its own artefact. Linear, GitHub, Asana and Atlassian all post a dated update with a health rating. Here the summary is a draft made from data; I write the update (see the open questions).
- Time-bound by design. A Shape Up bet ends by default. Here a workstream has
started,endedand an optionaltarget, and lint flags an active one past its target.
Hierarchy decision¶
A workstream may be part_of one other workstream, two levels at most
(MAX_WORKSTREAM_DEPTH = 2 in lib/lineage.py). Cycles are refused like any family of links. An
item belongs to exactly one workstream, the inner one or the outer one. A summary or count of the
outer workstream includes its children's items, tagged with the child's id.
Why two levels: it covers the common case, an umbrella effort such as "Widget platform" over "Widget search" and "Widget UI", with no ambiguity about where an item counts. A third level would mirror a tracker's portfolio hierarchy, which belongs in the tracker; the epic link already connects to it. The limit is one constant, so it can be raised if a real need appears. When an item's lineage parents are in an outer and an inner workstream, the inner one wins: it is the more precise answer, and it lies within the other.
Data model¶
- Kind
workstream, idws:<slug>(lower case letters, digits and dashes). States:active(initial),paused,done(terminal).doneis reachable from every state and can be reopened.ws set-staterecordsendedwhen it finishes one. Workstreams are never pruned. -
Fields:
epic: a ticket id, e.g.jira:WID-100(a bare key means Jira);channels: Slack channel ids;repos;started,ended,target: dates;area: a knowledge-graph entity id, e.g.feature/widget-search;aliases: other names it answers to.
The title is free to change, and the id never does. Every
<ws>argument takes the id, the slug or an alias in any case. Aliases are unique across workstreams. A rules file may use any of these names, so a rename never breaks it.
- Relation
part_of(inversehas_part, familyworkstream). For a work item, it names its one workstream. For a workstream, it names its parent. A secondpart_ofis refused unlesslink --moveis given, which records leaving the old one. A workstream takes onlypart_ofandrelates_to: it is a container, not work with a lineage. Tickets take none: their epic speaks for them. - Ticket parents.
ticket-sync <id> --parent <key>(alias--epic) records Jira'sparentfield (the epic of a story, the story of a sub-task) asfields.parent_ticket.
Resolution (computed on read, never copied)¶
An item's workstream is the first of these that gives one:
- explicit: its own
part_oflink; - inherited: the workstream of where it came from. Lineage parents come first:
spawned_from,produced_byand an outreach'sparent. Then what carries it on:folded_intoandduplicate_of. The parent's own answer counts, however it was found, andshownames the ancestor and that answer's basis; - epic: a ticket it
tracksthat is theepicof a workstream, or whose recorded parent chain reaches one, up to three hops (sub-task, story, epic); - rule: a rule in
$BOSUN_CONFIG_DIR/workstreams.json, or a workstream's ownchannels. Rules apply only to items created within the workstream'sstarted..endeddates; - unassigned: shown everywhere (
list --ws unassigned, the summary's last section, the console filter, stats), never hidden.
Workstreams and tickets themselves resolve to none (they are not work). ledger show <id> prints
derived.workstream: the id, the basis and how it was found, for example inherited from
mypr:acme/widgets#12 (explicit) or epic: tracks jira:WID-102 -> jira:WID-101 -> jira:WID-100
(the epic of ws:widget-search).
Rules file (site-specific, in the config dir; a generic example is
examples/generic/workstreams.json):
{"rules": [
{"ws": "widgets", "repo": ["acme/widgets", "acme/widget-*"], "title": ["widget", "search"]},
{"ws": "ws:widget-search", "channel": "C0000000042", "kind": ["mention", "outreach"]}
]}
Every criterion given must hold; within one, any value will do. repo takes globs and is read
from the item's id, its repo field or a GitHub URL. title takes whole words or phrases, in any
case, matched against the title (and an outreach's subject). channel is the origin Slack
channel: a mention's own, a review request's permalink, an outreach's messages and reply, a task's
via. kind narrows the rule and is refused on its own. A rule that cannot be used (an unknown
workstream, an unknown key, a malformed value) is left out and reported by lint. A file that is
not JSON is an error.
Ambiguity guards¶
- Disagreement at one level leaves the item unassigned, with the candidates named. Parents in
different, unrelated workstreams; tickets pointing at different workstreams; two rules matching
different ones; an epic claimed by two workstreams.
showsays "unassigned: where it came from (a, b) points at ws:x, ws:y (pick one with part_of)". The item's descendants inherit the ambiguity, but lint reports it once, where it arises. - Only roots are tagged.
assign --applywritespart_ofonly from epic and rule resolutions. Such a resolution exists only when nothing above the item has a workstream (inherited comes first), so these are always the topmost items of their lineage. Everything below inherits the tag. - Uncertain means proposed.
assignprints conflicts aspropose:lines with the exactlinkcommands and never applies them. The orchestrator asks me, as the policy says. - Lint (
ledger lint [--json] [--strict]):rule-vs-inherited: the item inherits one workstream, but its own epic or rule names another one that is not nested with it;overrides-parent: an explicit tag differs from what its parents give, which is fine when meant;redundant: an explicit tag equal to what it would inherit anyway;ambiguous: a conflict, proposed;unassigned-growing: more open, unassigned roots of my own work (PRs, tasks, outreach) created this week than last week, and at least three;done-with-open-items,past-target,epic-claimed-twice, andrules(unusable rules).
Summary¶
ledger summary --ws <ws> [--since <date|ISO time>] [--markdown] is a stand-up draft made from
the ledger. --all gives one per top-level workstream, then Unassigned. The default since is
this Monday, 00:00 local. Sections:
| Section | What | When |
|---|---|---|
| Shipped | PRs merged, tasks done, reviews given, design reviews commented | entered since |
| In review | my open PRs, with whom they wait on (reviewers, me, the merge) and open outreach under them | now |
| Waiting on others | outreach that went out, by recipient | now |
| Decisions | replies to decision, approval and clarification outreach | replied since |
| Blocked | items with open blocked_by items; outreach given up on while its work is open |
now |
| Next | proposed, accepted and delegated tasks, reviews asked of me, mentions to answer, drafts to send | now |
Each open item lands in one section only (blocked, then in review, then waiting, then next). Items of a child workstream carry its id. In markdown, items link to their URL.
Decided (2026-10-21): the chores' Monday morning run offers a draft for each active workstream
unprompted, since the previous Monday, as a draft under Needs your approval (a proposal with the
exact Slack call if I want it posted); see orchestrator/plugin/commands/chores.md.
Example (acme)¶
ledger ws add widget-platform --title "Widget platform"
ledger ws add widget-search --title "Widget search" --parent widget-platform \
--epic WID-100 --channel C0000000042 --repo acme/widgets --started 2026-10-01 --alias widgets
ledger link mypr:acme/widgets#12 part_of widgets # the root; its outreach and tasks inherit
ledger ticket-sync jira:WID-101 --status "In Progress" --parent WID-100 # its PRs now resolve by epic
ledger assign --dry-run # rule and epic matches, and proposals
ledger summary --ws widget-platform --since 2026-10-19 --markdown
Knowledge graph¶
The knowledge graph holds long-lived facts, and a workstream is a ledger item with a lifetime. So the knowledge graph does not mirror workstreams as entities: two sources of truth would drift, and a finished workstream would leave a stale entity behind. Instead:
- a workstream names its product area in the
areafield, as a knowledge entity id (feature/widget-search). It is stored now and not validated against the knowledge base; - later,
kg context --for ws:<slug>can build a context pack from the area's facts plus the workstream's repos, so delegated tasks in a workstream get the right pack; - decisions recorded during a workstream (its Decisions section) are candidates for
decisionfactsaboutthe area. Learning can propose them with the ledger item as the source.
Implementing these needs the knowledge pass of the learning run (MVP 3+), so it is design only for now.
Console¶
/api/items rows carry workstream (the resolved id; a workstream row carries its own id; "" for
none). ?ws=<id|alias|unassigned> filters, including the workstreams under the one given, and an
unknown name is a 400. The item page model carries workstream_how. /api/stats adds
workstreams: items opened and closed in the period per top-level workstream, and the
unassigned ones. The console reads the same workstreams.json from the config dir. The page has
no UI for these yet; that is a later step.
Open questions¶
- Should
summarybe saved as a dated status update with a health rating (on track, at risk, off track), so the next one can show what changed, as Linear and GitHub do? - Should a spawned task with no evidence of its own take its source's workstream into its delegation brief (with the area's context pack), once context packs exist?
- Reviews of colleagues' PRs are mostly unassigned. Should a review count toward a workstream when
the PR's repo and title match its rules (today it does; a rule can narrow
kind)?