Task pickup order — sdlc task next
Status: open/accepted
Summary
Section titled “Summary”- Pickup order is owned by one deterministic verb,
sdlc task next(plugin/lib/model/entities/task/ops/next.ts); both/sdlc:orchestrateand the no-arg/sdlc:task-workshell out to it instead of carrying duplicate prose. - The default sort chain is five keys applied highest-weight to lowest:
priority(boolean),impact(enum),complexity(enum, cheaper-first),created(oldest-first), filename (lexical tiebreak). - A dependency-lift pass propagates a dependent’s lead sort-tuple to its
depends_ontargets, so a low-impact blocker of high-impact work ships first; the lift runs to a fixed point. - By default the verb filters out candidates whose
depends_onis unsatisfied (a dependency-blocked task is never dispatched);--include-blockedrestores the full sort + lift view. This is why the verb isnext(select dispatchable work), notsort. priority: trueis the sparse human override valve; per-project chain customization viasdlc.yamlis explicitly deferred past v1.- The hand-rolled frontier/lift/cycle/dispatchability logic will be re-based onto the
graph-scheduler library (
D-BPD8-graph-scheduler-api) as its first consumer, keeping this behavior but retiring the duplicate implementation. See Re-basing onto graph-scheduler.
Status
Section titled “Status”Accepted. The sort logic is implemented in plugin/lib/model/entities/task/ops/next.ts
and pinned by plugin/lib/model/entities/task/ops/tests/next-golden.test.ts. The priority:
field is part of task schema v5. Per-project customization is
deferred (below).
Re-basing onto graph-scheduler
Section titled “Re-basing onto graph-scheduler”next.ts’s handler is a getReady(graph, state) by hand — build edges, detect
cycles, lift priority, drop unsatisfied depends_on, cap at N. The
graph-scheduler library (PR-DZTZ-graph-scheduler,
D-BPD8-graph-scheduler-api) generalizes exactly that, so sdlc task next
becomes its first consumer / reference adapter
(T-8I15-adapt-task-next-onto-graph-scheduler): the adapter builds the graph
(cross-entity depends_on resolved via corpus/resolve.ts), derives each node’s
Status from SATISFIED_BY_TYPE, and calls getReady. The DAG core + cycle
detection T-7EJO-extract-corpus-depgraph-module extracted into
plugin/lib/model/corpus/graph.ts is replaced by graph-scheduler/graph.
This is behavior-preserving — the five-key chain, the lift, the default
dispatchability filter, --include-blocked, and --explain all survive — but it
requires four consumer-driven additions to the library, recorded in
D-BPD8-graph-scheduler-api §First consumer. The load-bearing one is the
lift: graph-scheduler’s weight_rule: downstream sums a scalar weight,
while this decision’s lift propagates a lexicographic sort-tuple by
max-to-fixed-point — so the library needs comparable-key downstream propagation,
as the default sorter for the SDLC adapter, before the cutover can preserve
ordering.
Context
Section titled “Context”/sdlc:orchestrate and /sdlc:task-work (no-arg form) both decide which
task to pick up next. Carrying that decision as prose in each skill would
duplicate it and leave triage signal unused: a bare sort by created: has
no human override, ignores impact: / complexity: (already captured at
intake), and cannot lift blockers of higher-priority work. The pickup-order
algorithm therefore lives in exactly one place, owned by the deterministic
sdlc task next verb, and both consumer skills shell out to it.
The verb’s --help is the operational reference; the verb’s tests
(plugin/lib/model/entities/task/ops/tests/next-golden.test.ts) are the executable spec.
Decision
Section titled “Decision”The default sort chain is five keys, applied highest weight to lowest:
| # | Key | Type | Order | Reads as |
|---|---|---|---|---|
| 1 | priority | boolean | true above false/absent | ”do this one first” — human override valve |
| 2 | impact | enum high/medium/low | high → medium → low → missing | ”value to the project of doing this work” |
| 3 | complexity | enum small/medium/large | small → medium → large → missing (cheaper-first) | “how big is this” |
| 4 | created | ISO date | oldest first | anti-starvation tiebreak |
| 5 | filename | string | lexical | deterministic, byte-stable final tiebreak |
1. priority (boolean)
Section titled “1. priority (boolean)”The human-set override valve. Setting priority: true pulls a task above
every priority: false (or absent) task, regardless of impact,
complexity, or age. Absent means false; most tasks don’t need this field.
The intent is sparse use: when the author wants “do this one first”
without re-dating the task or inflating its impact.
Boolean by design. Numeric priority (P0/P1/P2, 1..5) was considered and
ruled out for v1 — boolean is the simplest shape that expresses the “lift
one task above everything else” need, and impact: already covers
ordering among tasks that matter. “First among prioritized,” if it becomes
a recurring need, is a future bump.
2. impact (enum)
Section titled “2. impact (enum)”The task’s triage value, captured at intake and validated by the schema.
high sorts above medium above low above missing. The dominant
categorical signal once priority is settled.
3. complexity (enum)
Section titled “3. complexity (enum)”Rough effort: small (< 1 day), medium (1–3 days), large (multi-day
or multi-PR), then missing. Ranks lower-first (small > medium > large)
so among same-impact tasks the cheaper one ships first — small wins
compound, and a half-finished large task is more disruptive than a small
one shipped end-to-end.
4. created (ISO date)
Section titled “4. created (ISO date)”Oldest first — the anti-starvation tiebreak, so a task can’t be starved by a stream of higher-impact arrivals. Among tasks of the same priority, impact, and complexity, the one sitting longer wins.
5. Filename
Section titled “5. Filename”The final tiebreak. Two tasks with identical date and priority/impact/complexity sort lexicographically by filename — deterministic, no surprise reorderings between runs.
Dependency lift
Section titled “Dependency lift”Sort by frontmatter alone misses a coordination problem: a low-impact task
that blocks a high-impact task should ship first, but its own frontmatter
says otherwise. The depends_on: field captures these edges; the sort verb
walks them during a lift pass.
For each candidate, the verb walks the depends_on graph and propagates
sort-tuple lifts from dependents (tasks with a depends_on entry) to
their targets (the tasks pointed to). A target inherits the lead-tuple
(priority, impact, complexity, created) of any dependent that sorts
strictly earlier — so a low-impact blocker of a high-impact dependent is
pulled forward to the dependent’s position. The lift runs to a fixed point;
chains work end-to-end.
Closed targets
Section titled “Closed targets”Closed tasks (anything matching closed/* — closed/done,
closed/superseded, etc.) are not pickup candidates, so they never appear
in output regardless of what points at them. They also do not contribute to
the lift (their tuple is dropped from the propagation pool). A task pointing
at a closed dependency is no longer blocked; nothing is lifted in its honor.
Cycles
Section titled “Cycles”A depends_on cycle (A → B → A, or any longer loop) is a spec error the
schema can’t catch (JSON Schema is single-document). The verb detects cycles
via DFS color marking and exits 1 with a stderr marker:
CYCLE basenames=<comma-separated-basenames>Cycles always need human attention. The orchestrator and task-work both treat a cycle exit as terminal for that tick, surface the marker, and stop dispatching until the cycle is resolved (typically by removing one edge).
Unresolved targets
Section titled “Unresolved targets”A depends_on entry pointing at a non-existent entity (typo, renamed
slug) is logged to stderr as a warning but does NOT fail the run. It does,
however, count as unsatisfied for the dispatchability filter below
(fail-safe — don’t dispatch against a dangling dependency). The canonical
place to catch missing targets is /sdlc:entities-audit.
Dispatchability filter
Section titled “Dispatchability filter”The lift orders a blocker ahead of its dependent, which suffices for a
sequential consumer (pick one, ship it, the dependent unblocks). A
parallel consumer — /sdlc:orchestrate dispatches up to
max_implementations sub-agents per tick — needs more: a dependent must
not be picked up at all while its blocker is unshipped. So sdlc task next
filters out any candidate with an unsatisfied depends_on target by
default. (That is the name’s rationale: the verb selects dispatchable
work, not merely sorts — hence next, not sort.)
A target is satisfied (the dependent may proceed) per a per-entity-type
band, resolved across the whole docs/planning/ corpus so cross-entity
dependencies are gated too:
| Entity type | Satisfied iff status in |
|---|---|
| task | closed/* |
| decision | open/accepted, closed/superseded, closed/deprecated |
| standard | open/active, closed/superseded, closed/deprecated |
| principle | open/published, closed/retired |
| milestone | closed/done, closed/partial, closed/superseded |
| capability | open/verified, closed/retired |
| driver | open/validated, closed/resolved, closed/retired |
| product | open/active, closed/sunset |
| reference | open/active, closed/retired |
| term | open/active, closed/retired |
| backlog | promoted/*, closed/* |
There is no epic type — epic-ness derives from a task’s parent_key, so
an epic dependency is a task dependency. The bands live as the
SATISFIED_BY_TYPE constants map in plugin/lib/model/corpus/satisfied.ts,
pinned by a test (plugin/lib/model/corpus/tests/satisfied.test.ts)
asserting each band is a subset of the type’s status enum.
An unsatisfied (or unresolved) target drops the candidate from stdout,
reported on stderr as skipped-blocked: <basename> (depends_on unsatisfied: …) and, in --output json, in the skipped_blocked array.
--include-blocked disables the filter and restores the pure sort + lift
view (human triage, --explain, debugging). A fully-blocked or cyclic
backlog therefore yields an empty default output; callers distinguish
“nothing dispatchable” from “nothing exists” (orchestrate logs the former
as tasks-dispatched=none-empty).
The priority: field
Section titled “The priority: field”priority: is the explicit override on the sort chain. The default should
be sparse — most tasks express urgency via impact: plus created:, and
using priority: true widely defeats the dominant categorical signal.
When to set it:
- You need to land one specific task before everything else, and bumping
its
impact:tohighwould lie about its actual value. - A blocker for upcoming work has surfaced and there’s no
depends_on:edge to capture it yet. - A short interrupt task needs to land before the queue resumes.
How to set it:
- At intake: pass
--prioritytosdlc task create. - Post-creation: edit the frontmatter, set
priority: true. The schema validator accepts the field; entities-audit treats it as a normal optional field. - To un-prioritize: remove the key entirely (preferred — keeps frontmatter
compact) or set
priority: false. The human manages the flag’s lifecycle; the verb does not auto-decay it.
Where the default lives
Section titled “Where the default lives”The default sort chain is hard-coded in plugin/lib/model/entities/task/ops/next.ts and
pinned by plugin/lib/model/entities/task/ops/tests/next-golden.test.ts. Changing the chain means
editing the constants and the assertions in lockstep. Adding priority:
cost a one-time schema bump (to v5); further customizations land via the
same mechanism (schema bump if a new field is needed, test update
otherwise).
Per-project customization (deferred)
Section titled “Per-project customization (deferred)”A future option is per-project sort customization via sdlc.yaml:
task_sort: - priority - impact - created(dropping complexity:, or reordering the chain). That config is explicitly
not in v1 — the default chain is opinionated, and we should wait for at
least one project to bump up against the default before adding the knob.
When demand surfaces, a follow-up task adds the config-reading path; the
algorithm itself won’t change.
Worked example
Section titled “Worked example”Given this inventory under docs/planning/tasks/:
| Basename | priority | impact | complexity | created | depends_on |
|---|---|---|---|---|---|
2026-05-01-old-medium | — | medium | medium | 2026-04-01 | — |
2026-05-02-small-high | — | high | small | 2026-05-15 | — |
2026-05-03-large-low-priority | true | low | large | 2026-05-20 | — |
2026-05-04-blocker-low | — | low | medium | 2026-05-20 | — |
2026-05-05-blocked-high | — | high | medium | 2026-04-15 | 2026-05-04-blocker-low |
2026-05-06-closed-blocker | — | high | small | 2026-04-01 | — (status closed/done) |
sdlc task next --include-blocked --status open/ready --exclude-autonomy human-only emits:
2026-05-03-large-low-priority—priority: truelifts it above everything else.2026-05-02-small-high— highest impact + small complexity among the unpinned set.2026-05-04-blocker-low— base tuple is low/medium, but it inherits the lift from2026-05-05-blocked-high(high/medium), pulling it ahead of2026-05-01-old-medium.2026-05-05-blocked-high— unchanged base tuple (high/medium); sorts after its now-lifted blocker.2026-05-01-old-medium— oldercreated:but same medium/medium tuple; no lift applies.
2026-05-06-closed-blocker does not appear (closed) and contributes nothing
to the lift even if something pointed at it.
Without --include-blocked (the default), 2026-05-05-blocked-high is
filtered — its depends_on blocker 2026-05-04-blocker-low is still
open/ready, not satisfied — so the dispatchable output is items 1, 2, 3,
5, with blocker-low surfaced ahead of old-medium by the lift, ready to
ship first. blocked-high is reported on stderr as skipped-blocked and
becomes dispatchable once blocker-low reaches closed/*.
See also
Section titled “See also”plugin/cli/task_cli/README.md— package shape and developer surface.plugin/lib/model/entities/task/ops/tests/next-golden.test.ts— executable spec of the sort chain and the lift.plugin/lib/model/entities/task/schema.ts— task frontmatter fields, includingpriority:.orchestrateStep 4 — the consumer.task-workStep 1 (no-arg branch) — the consumer.