overviewdocs/PLANNING.md

How we decide what to do next

How the next unit gets chosen

How we decide what to do next

Written on 2026-09-17, during the conversation that produced it, because the owner asked for the planning process to be recorded rather than re-derived. It describes a mechanism that is half built, and it names what is missing as plainly as what exists - the gaps are the useful half.

The problem, measured

Every figure below is from docs/state.json, and the definitions are its too, not restated here. scripts/gen_state.py:291 is the only place the frontier is defined:

an unbuilt ticket with no unbuilt blocker

That single line is the authority. This document restated it in prose once and produced a third incompatible spelling - OPS-147 - so it now points rather than paraphrases. Run python3.12 scripts/gen_state.py for the current numbers; the ones below were true at the last regeneration.

tickets504
unbuilt and dispatchable242
blocked_by edges62
the unblocked frontier232
connected components29, largest 11
dispatchable at derived P016

Two failures, and they compound.

Nothing says what waits on what. 62 edges leave a frontier of 232, so anything asking "what is next" reads 232 tickets and prioritisation is a whole-backlog operation every time. This paragraph has now carried three different figures - 397, 229 and 232 - because it kept recomputing instead of reading state.json. The conclusion never changed and the number never should have been written here at all.

Nothing revises a score after it is written. value and urgency are self-reported once, at write time, and never revisited - so a priority is a claim about a ticket's author as much as about the ticket. This paragraph originally said the scale had saturated, on the strength of 67 P0s; that counted the declared field over a pool including built work, and over the 242 dispatchable it is 16. The saturation claim is withdrawn and the never-revised claim is what stands.

Why decomposing the problem does not fix it

The obvious answer is hierarchy: group the tickets, prioritise inside each group, then prioritise between the winners. The owner's objection to his own suggestion is correct and worth keeping:

I would argue that you could find cases where doing this won't be the optimal or even nearly the optimal way. You had to have to understand everything, to understand that there's one specific thing that is best done first.

That is not a tuning problem. A globally critical item that ranks second inside a strong group never meets the winner of a weak one, so decomposition structurally suppresses it, and no amount of better scoring recovers it.

This repository has the live instance. Deploying the one product a customer can touch was, on 2026-09-16, not a ticket at all - and had it been one it would have competed with every other P0 in the products lane. Any planner promoting each lane's best item would never have surfaced it. The count first written here was eleven, over the wrong population - the argument does not depend on the number, which is why it survived the correction and the saturation claim did not.

Two things help, and neither is an algorithm.

Group by outcome, not by area. The lanes - components, ops, docs, core, products, cli - are skill groupings. "The best components ticket" versus "the best docs ticket" is a comparison with no shared unit, which is exactly where the suppression happens. Epics shaped as outcomes - gets us a paying customer, stops a defect reaching a user, makes the factory faster - make the within-group comparison meaningful and turn the across-group question into a strategy call taken occasionally rather than a ranking re-solved constantly.

Prefer a signal that survives grouping. A ticket that unblocks eight others is important however you partition the backlog, because the fact is about the graph and not about the group. Centrality is the only prioritisation signal here that cannot be self-reported - it is counted rather than claimed, so optimism cannot inflate it. It is also currently unavailable: the largest connected component is 11 tickets, so maximum out-degree is about three.

That is the third independent argument for the dependency graph, and the strongest. The graph is not backlog hygiene. It is the only route to prioritisation that does not require somebody to hold the whole frontier in somebody's head.

The three moves, in dependency order

1. Derive candidate edges. ~~Planned~~ done, OPS-144, and it changed the answer. scripts/propose_edges.py --shortlist produces 129 candidates and the frontier would fall 232 to 131 once they are ruled on. Nothing writes a blocked_by field; the shortlist is evidence and judgement stays with a person.

Two results worth more than the number. see_also recovers 52 of the 62 hand-written edges where file paths recover 12 - a pair citing each other is sixteen times likelier to carry a real edge than one merely sharing a rare path, and nobody had tested that. And the naive method's confident answer, 397 to 247, was an artefact: 483 of its 486 directed candidates point at a blocker that is already built, so they are accurate, well-pointed and no-ops. The direction rule is driven by the stage gap, which means the arrow it is good at producing is the arrow nobody needs.

plan_batch.inferred_files is the input, and it crashed on its first ever invocation until 2026-09-16, so nobody had used it for anything.

The trap: naive co-occurrence produces thousands of false candidates, because CLAUDE.md and docs/LESSONS.md are named by dozens of tickets each. A shared rare path is evidence; a shared common one is noise. So candidates are weighted by path rarity.

And the hard constraint: nothing writes an edge automatically. A wrong edge is worse than a missing one - it makes a ready ticket look blocked and no gate would catch it. The output is a ranked list with its evidence, and judgement stays with a person. The number that decides whether the idea works is how far the frontier falls - measured, not estimated.

2. Feed centrality into the score. Once edges exist, a ticket's out-degree becomes a scoring input that nobody can inflate. This depends on move 1 and does not start before it.

3. Outcome epics. Fifteen to twenty-five of them, replacing the lane as the grouping that prioritisation uses. This is judgement rather than derivation: proposed by the coordinator, ruled on by the owner.

What is missing from this plan

Named on 2026-09-17 in answer to a direct question, and ordered by how much each one undermines the rest.

~~There is no stated finish line.~~ Closed 2026-09-17 - the owner stated it and it is in docs/PURPOSE.md under "The finish line": ninety percent of a SaaS's infrastructure from one click including the migrations between scale stages, nine percent more from one product-level interview, and a standard enforced by design rather than by discipline. This paragraph stays struck rather than deleted only because the outcome epics below now have their input and somebody should see where it came from.

Nothing measures the cost of a product end to end. The stated purpose is to ship SaaS products serially, so the number that matters is tokens and days to ship product N. tokens per unit measures a step, not an outcome. Only one product exists and it predates measurement, so the figure is unknown.

Every move above is about the factory's queue, and the product is not in the loop. Edges, centrality and epics all make the backlog easier to plan. None of them makes the one live product better, and it is the only thing with a user. A plan whose every item improves the planning is a plan with a smell.

~~Nothing retires a ticket.~~ Closed 2026-09-17 by OPS-149, which the owner asked for in these words: "if a ticket was lying around for, like, a certain amount of time, then we revise it and keep it, like, a parameter that we can play with." docs/REVIEW-SWEEP.md is the sweep and scripts/check_ticket_lifecycle.py is how it is regenerated. It proposes withdrawals and never writes one, on this document's own argument about edges.

Two things it found are worth keeping rather than striking. The queue did not only grow - four ticket ids had left the register, deleted rather than withdrawn, and --deletions is the check that had never existed. And seeding the review dates from each ticket's filing date does not spread the queue here: this backlog was written in one week, so 247 tickets come due across six mornings with 80 on one of them. The threshold decides who is eligible and a separate limit decides how many are asked for, which is the correction this paragraph would otherwise have hidden.

Nothing re-scores when the world changes. Every value and urgency was set once, at write time, and never revisited. When GitHub Actions stopped starting jobs on 2026-09-14 the correct response was to promote everything that depended on CI; nothing did, because nothing can.

OPS-150 is the design question, written down rather than answered: five options, sorted by whether the signal can be recomputed from something the repository already measures or only re-asserted by somebody having an opinion again. Only the first kind scales and only the second can carry judgement, and the sweep above currently offers the second alone.

The dashboard has no write path. The owner will read it and want to act - approve, reprioritise, answer an open question. Read-only means he returns to a chat session to do anything, which removes about half the point of having it.

Only move 1 has a success criterion. The frontier falling is measurable. What a successful epic set or a successful dashboard looks like is not stated, and unstated criteria are how a thing gets declared finished by whoever is tired.

How this document should be used

It is a record of reasoning, not a rule with an enforcer, and it will go stale the way docs/METHOD.md warns about. The parts worth keeping are the argument for centrality and the list of gaps. When a gap closes, delete its paragraph - a plan that still lists a solved problem is worse than no plan, because it is trusted.

Correction, 2026-09-17: which arguments survived the real numbers

OPS-143 built docs/state.json and found that "live" meant three different things here, none of them stated. Recorded rather than quietly edited, because the pattern is more useful than the fix:

argumentstatus
decomposition suppresses a globally critical itemholds. Structural, and independent of any count
the frontier is too large to readholds, smaller. 229 rather than 397
centrality is unavailableholds fully. 62 edges, largest component 11, max out-degree about three
priority has saturatedwithdrawn. 16 of 242 is an ordinary rate

Three of four stand. The one that fell is the one I reached for hardest, and it fell because I counted a convenient population rather than asking which population the question was about. Every count in this document now comes from state.json, which names its own.