Skip to content

Task pickup order — sdlc task next

Status: open/accepted

  • Pickup order is owned by one deterministic verb, sdlc task next (plugin/lib/model/entities/task/ops/next.ts); both /sdlc:orchestrate and the no-arg /sdlc:task-work shell 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_on targets, 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_on is unsatisfied (a dependency-blocked task is never dispatched); --include-blocked restores the full sort + lift view. This is why the verb is next (select dispatchable work), not sort.
  • priority: true is the sparse human override valve; per-project chain customization via sdlc.yaml is 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.

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).

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.

/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.

The default sort chain is five keys, applied highest weight to lowest:

#KeyTypeOrderReads as
1prioritybooleantrue above false/absent”do this one first” — human override valve
2impactenum high/medium/lowhigh → medium → low → missing”value to the project of doing this work”
3complexityenum small/medium/largesmall → medium → large → missing (cheaper-first)“how big is this”
4createdISO dateoldest firstanti-starvation tiebreak
5filenamestringlexicaldeterministic, byte-stable final tiebreak

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.

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.

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.

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.

The final tiebreak. Two tasks with identical date and priority/impact/complexity sort lexicographically by filename — deterministic, no surprise reorderings between runs.

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

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).

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.

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 typeSatisfied iff status in
taskclosed/*
decisionopen/accepted, closed/superseded, closed/deprecated
standardopen/active, closed/superseded, closed/deprecated
principleopen/published, closed/retired
milestoneclosed/done, closed/partial, closed/superseded
capabilityopen/verified, closed/retired
driveropen/validated, closed/resolved, closed/retired
productopen/active, closed/sunset
referenceopen/active, closed/retired
termopen/active, closed/retired
backlogpromoted/*, 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).

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: to high would 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 --priority to sdlc 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.

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).

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.

Given this inventory under docs/planning/tasks/:

Basenamepriorityimpactcomplexitycreateddepends_on
2026-05-01-old-mediummediummedium2026-04-01
2026-05-02-small-highhighsmall2026-05-15
2026-05-03-large-low-prioritytruelowlarge2026-05-20
2026-05-04-blocker-lowlowmedium2026-05-20
2026-05-05-blocked-highhighmedium2026-04-152026-05-04-blocker-low
2026-05-06-closed-blockerhighsmall2026-04-01— (status closed/done)

sdlc task next --include-blocked --status open/ready --exclude-autonomy human-only emits:

  1. 2026-05-03-large-low-prioritypriority: true lifts it above everything else.
  2. 2026-05-02-small-high — highest impact + small complexity among the unpinned set.
  3. 2026-05-04-blocker-low — base tuple is low/medium, but it inherits the lift from 2026-05-05-blocked-high (high/medium), pulling it ahead of 2026-05-01-old-medium.
  4. 2026-05-05-blocked-high — unchanged base tuple (high/medium); sorts after its now-lifted blocker.
  5. 2026-05-01-old-medium — older created: 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/*.

  • 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, including priority:.
  • orchestrate Step 4 — the consumer.
  • task-work Step 1 (no-arg branch) — the consumer.

← Back to Decisions