osdlc

backlog-triage

Walk untriaged backlog files in docs/planning/backlog/ and decide what to do with each: promote to task, promote to milestone, close, defer (bump last_reviewed), or leave it for an open PR. Before each decision, checks the item against main: the code it names, merged and open PRs, and existing planning entities, and recommends an action with quoted evidence. Promotion calls `sdlc task create` / `sdlc milestone create` directly, or points at existing work that already covers the item, and updates the backlog frontmatter to match. Commits each decision atomically on the current branch, honouring the project's branching conventions.

osdlc 3 Updated 6d ago

Resources

1
GitHub

Install

npx skillscat add osdlc/osdlc/backlog-triage

Install via the SkillsCat registry.

SKILL.md

$sdlc:backlog-triage

Usage:

  • $sdlc:backlog-triage — walk every untriaged backlog file.
  • $sdlc:backlog-triage <slug-or-path> — triage just the named file.

Untriaged ≡ frontmatter has no state: field. Any task with state: set (whether promoted/* or
closed/*) is considered already triaged and skipped by the no-arg pickup.

Project context (don't re-derive every run):

  • Backlog files live at docs/planning/backlog/<basename>.md, where the basename is
    B-<id>-<slug>. Schema at ${CLAUDE_PLUGIN_ROOT}/lib/model/entities/backlog/schema.ts.
  • Two usage patterns coexist (see the template head comment). This skill targets
    single-candidate files — those representing one idea that may get promoted. Multi-item dump
    files (where the body holds many ideas) typically don't ever get state: set; for those, this
    skill offers "defer" (bump last_reviewed) but the per-item promotion is up to the author.
  • A backlog file records an idea at the time it was written. The code may have changed since. Never
    decide from the backlog text alone; step 2 checks each item against main first.
  • Frontmatter mutations go through sdlc backlog update <backlog> --set '<json>' (schema-validated
    before write, body byte-untouched, a null value deletes a key); the standalone validator is
    sdlc entities validate.
  • Promotion writes both a new task/milestone file AND updates the backlog frontmatter. Both go in
    one commit per decision so the link is atomic.
  • Commit each decision on the branch you are already on. Do NOT switch branches, and do NOT assume
    main is committable — a project may require that its main checkout stay clean and that all
    work happen on a branch or worktree. Read the project's own conventions before the first commit.
    If they forbid committing on main and that is where you are standing, stop and ask the user
    where to work rather than committing anyway. Whether the run ends in a PR is the caller's
    decision, not this skill's.

1. Survey

If an argument was given, resolve it:

  • Absolute path → use as-is if the file exists under docs/planning/backlog/.
  • Otherwise, glob docs/planning/backlog/<arg>*.md then docs/planning/backlog/*<arg>*.md. If
    exactly one matches, use it. If multiple match, present the list via AskUserQuestion and let the
    user pick. If none, exit NO BACKLOG FOUND for "<arg>".

If no argument, list docs/planning/backlog/*.md and read frontmatter from each. Filter to the
untriaged set (no state: field at all). Sort by filename (alphabetical). If the untriaged set is
empty, exit NO UNTRIAGED BACKLOG FILES.

Report at the start: how many files will be triaged, and their slugs.

2. Check against main

Check every item before deciding it. Fetch first so the check sees current main:

git fetch -q origin main

For each item:

  1. Code. Find the files, functions, commands or config the item names on origin/main
    (git show origin/main:<path>, git grep <term> origin/main). Quote the current lines that
    show whether the problem still exists. If a path is gone, follow the rename
    (git log --follow --diff-filter=R origin/main -- <path>) and quote the new location.

  2. PRs. Search merged and open PRs for the item's key terms:

    gh pr list --state merged --search "<terms>" --limit 20
    gh pr list --state open --search "<terms>" --limit 20
  3. Commits. Search history since the backlog file was created (its created: field, or the
    date of the commit that added it):

    git log origin/main --since <created> --oneline --grep "<term>"
    git log origin/main --since <created> --oneline -S "<identifier>"
  4. Planning. Search docs/planning/ for tasks, milestones, decisions and other backlog entries
    that already cover the item. Match on content — the problem and the code it touches — not on
    slug or headline alone.

Pick one verdict per item from its evidence:

Verdict Meaning
delivered Main already does what the item asks; cite the PR or commit.
abandoned The item no longer applies (the code it names is gone, or moot).
duplicate/existing-work A task, milestone or backlog entry already covers it; name it.
in-progress An open PR addresses it; name the PR.
promote The problem still exists on main and nothing covers it.
defer Still real, but not ready to act on.
unsure The evidence does not settle it; say what is missing.

A promote verdict needs quoted current lines from main that show the problem exists. Without
them, the verdict is unsure.

Record what the check found that changes the item's scope: moved paths, renamed things, and parts
already shipped. Step 4 writes this into the new artifact.

Batch mode

For a run over more than a few files, do the check in read-only subagents before asking the user
anything. Give each subagent a few items. Each subagent runs the four checks above and returns, per
item: the verdict, the quoted evidence from main for it, any PR or entity it names, and the scope
notes. A verdict without quoted evidence from main counts as unsure. Subagents never edit files
or commit.

Then present the decisions grouped by verdict, one line of evidence per item, and confirm each
group with the user before step 3 applies it.

3. Per-file decision loop

For each file in turn:

  1. Read the file. Capture:

    • Frontmatter (current state, tags, last_reviewed).
    • Headline (the first # line in the body, stripped).
    • Body summary — the first ~200 chars after the headline. If the body is short, show the whole
      thing.
  2. Present the file to the user as a short text block: slug, headline, current tags, last_reviewed
    (if set), then the body summary. Then show the step 2 findings: the quoted evidence, the PRs and
    entities found, and the recommended action with the reason for it.

  3. If the verdict is in-progress, ask via AskUserQuestion: Leave for the open PR
    (recommended) or Triage now. Leaving it means no edit and no commit; the backlog stays
    untriaged, and the PR that closes it can update it without a clash. Name the PR in the wrap-up.
    On Triage now, continue to the next sub-step.

  4. Ask via AskUserQuestion (4 top-level options), marking the recommended one:

    • Promote to task — point this backlog at a task: an existing one that already covers the
      item, or a new one via sdlc task create. Sets state: promoted/task with
      result: [[<task-id>]]. Default action for items that map to one concrete change.
    • Promote to milestone — point this backlog at a milestone: an existing one, or a new one
      via sdlc milestone create. Sets state: promoted/milestone with
      result: [[<milestone-id>]]. For larger outcomes spanning multiple tasks.
    • Close — discard without promotion, or record that the item shipped without ever being
      promoted. A second AskUserQuestion asks the reason: abandoned (won't do / no longer relevant;
      no result: needed), duplicate (another backlog entry covers it →
      result: [[<other-backlog-basename>]]), or delivered (the idea was built directly, no
      task/milestone in between → result: is the PR link or a short note of what shipped).
    • Defer — bump last_reviewed: to today; no other change. Use when the item is still
      relevant but you're not ready to promote.
  5. Execute the chosen action (steps 4–7 below). Then continue the loop.

There is no explicit "skip" option. To leave an item undecided, interrupt the session: committed
decisions stay and the unprocessed files remain untriaged, resurfacing in the next run.

4. Promote to task

  1. Decide whether a task already covers this backlog. Start from the step 2 planning check. Then
    preview the slug + id task create would assign for the backlog headline, and surface any
    collision:

    scripts/sdlc task preview-id "<backlog-headline>" --output json

    The op is read-only: it derives the slug (the same capped deriveSlug create uses with
    --slug omitted) and reports the would-be id, plus exists/exact_match (an existing task
    with the identical slug) and similar[] (existing tasks with a close slug). If the input has no
    alphanumeric content it exits non-zero (INVALID_INPUT) — that's the only "empty slug" case;
    when it happens, ask the user via AskUserQuestion for a different headline to derive from.

    Then make the load-bearing "is this backlog already a task?" decision via AskUserQuestion:

    • If step 2 found an existing task that covers the item by content, offer reuse of that
      task. This is the usual case; slug matching alone misses it.
    • If exists is true (or exact_match is non-null), an existing task already carries this exact
      slug — offer reuse (point this backlog's result: at exact_match.id and skip creation)
      vs override (free-form text via Other for a distinct slug to pass as the positional below).
    • Else if similar[] is non-empty, surface those neighbors (their ids + slugs) and offer the
      same reuse-vs-proceed choice.
    • Else (no match of any kind) proceed straight to creation — no override needed.
  2. Create the task, unless the user chose reuse. With --slug omitted, create DERIVES the
    slug from the headline (the same derivation preview-id just showed), so don't hand-pass it.
    Only pass an explicit <slug> positional when the user chose override above:

    scripts/sdlc task create \
      --headline "<backlog-headline>" \
      --state planning/draft \
      --impact medium \
      --complexity medium

    Capture the printed path and the minted id. The task's canonical id is the T-<id> form
    preview-id reported (and create prints) — the schema-assigned id, not a dated basename. Use
    it for the result: wikilink.

  3. If step 2 found moved paths, renamed things, or parts already shipped, write the current scope
    into the new task body so the next reader does not redo the check. Fill the sections it informs
    (usually ## Today and ## Proposed), replacing each filled section's whole <...> template
    placeholder. Cite the quoted lines and the PRs found. Leave other sections as the template made
    them. Skip this sub-step on reuse; the existing task is not edited.

  4. Update the backlog frontmatter via sdlc backlog update (schema-validated; the body is left
    byte-untouched):

    scripts/sdlc backlog update <backlog-path> \
      --set '{"state":"promoted/task","result":"[[T-<id>-<slug>]]","last_reviewed":"<today>"}'

    The schema accepts [[T-<id>]] or [[T-<id>-<slug>]], with a 4-character base-36 id (for
    example [[T-77TR-learning-p8-retire-augmented]]). It rejects a date-prefixed basename. On
    reuse, write the existing task's id the same way. <today> is today (UTC, YYYY-MM-DD). The
    op fails non-zero if the resulting frontmatter does not validate — fix the --set payload and
    re-run before committing.

  5. Commit atomically — only the touched files; don't sweep in unrelated changes. Stage the exact
    paths (the backlog alone on reuse), then author the message through sdlc commit create
    (subject + body; no shell re-parse of the conventional-commit parens):

    git add <task-path> <backlog-path>
    scripts/sdlc commit create \
      --subject "docs(planning): promote backlog/<backlog-slug> to task/<task-basename>" \
      --body "<backlog-headline>"

5. Promote to milestone

Mirror step 4, but:

  • If step 2 found an existing milestone that covers the item, offer reuse of it and skip
    creation. Otherwise use sdlc milestone create instead of sdlc task create. The milestone op
    auto-assigns the next M-<id> — capture it from the printed path.

  • Write the current scope into the new milestone body the same way as step 4.3 (usually its
    ## Goal).

  • Update the backlog via sdlc backlog update with state: promoted/milestone and a milestone
    wikilink. The schema accepts [[M-<id>]] or [[M-<id>-<slug>]] (for example [[M-DCPV]]):

    scripts/sdlc backlog update <backlog-path> \
      --set '{"state":"promoted/milestone","result":"[[M-<id>-<slug>]]","last_reviewed":"<today>"}'
  • Stage the touched files and commit through sdlc commit create:

    git add <milestone-path> <backlog-path>
    scripts/sdlc commit create \
      --subject "docs(planning): promote backlog/<backlog-slug> to milestone <M-id>" \
      --body "<backlog-headline>"

The milestone create call:

scripts/sdlc milestone create \
  --title "<backlog-headline>" \
  --state open/draft

(Don't seed tasks or other fields — the milestone will be fleshed out later by $sdlc:milestone-new
follow-ups or by hand.)

6. Close

The user picked Close in step 3.4. Ask via AskUserQuestion which reason applies (3 options):

  • Abandoned — won't do, no longer relevant. No result: needed.
  • Duplicate — another backlog entry already captures it. Use the entry step 2 found, or ask in
    plain text: "Which backlog entry is this a duplicate of? (its basename, no [[...]])". Confirm
    the target file exists at docs/planning/backlog/<basename>.md; if not, ask again or offer to
    abandon instead. When a task or milestone covers the item, promote to it (step 4 or 5) instead;
    closed/duplicate accepts only a backlog wikilink.
  • Delivered — built directly, without ever going through promoted/task or
    promoted/milestone. Use the PR or commit step 2 found, or ask in plain text: "PR link or short
    note of what shipped?" → result: carries that text verbatim (a markdown PR link, e.g.
    [#1880](https://github.com/sksizer/dev/pull/1880), or a short freeform note — no wikilink shape
    required).

Then update the backlog frontmatter via sdlc backlog update:

  • Abandoned: the schema FORBIDS result: on closed/abandoned, so null-delete any prior
    result: in the same call.

    scripts/sdlc backlog update <backlog-path> \
      --set '{"state":"closed/abandoned","result":null,"last_reviewed":"<today>"}'
  • Duplicate: the schema requires a backlog wikilink, [[B-<id>]] or [[B-<id>-<slug>]].

    scripts/sdlc backlog update <backlog-path> \
      --set '{"state":"closed/duplicate","result":"[[<other-backlog-basename>]]","last_reviewed":"<today>"}'
  • Delivered: the schema REQUIRES result: on closed/delivered, freeform (not
    wikilink-shaped).

    scripts/sdlc backlog update <backlog-path> \
      --set '{"state":"closed/delivered","result":"<PR link or note>","last_reviewed":"<today>"}'

The op is schema-validated; it fails non-zero if the result does not validate. Then commit only that
file through sdlc commit create:

git add <backlog-path>
scripts/sdlc commit create \
  --subject "docs(planning): close backlog/<backlog-slug> as <reason>" \
  --body "<one-line note: backlog headline for abandoned; '\"<headline>\" — duplicate of <other-basename>' for duplicate; '\"<headline>\" — delivered in <PR link>' for delivered>"

7. Defer

  1. Update the backlog frontmatter via sdlc backlog update: set last_reviewed: to today. Do not
    change state:. The op is schema-validated and leaves the body byte-untouched.

    scripts/sdlc backlog update <backlog-path> \
      --set '{"last_reviewed":"<today>"}'
  2. Commit only that file through sdlc commit create:

    git add <backlog-path>
    scripts/sdlc commit create \
      --subject "docs(planning): defer backlog/<backlog-slug>" \
      --body "Reviewed but not yet promotion-worthy."

8. Wrap up

After the loop finishes (or the user stops it), print a short summary:

  • N files considered.
  • For each: the verdict from step 2, what happened (promoted to task/<basename> or
    promoted to existing task <id>, promoted to milestone M-<id> or
    promoted to existing milestone <id>, closed/abandoned, closed/duplicate of <basename>,
    closed/delivered, deferred, left for <PR link>), and the one piece of evidence it rests
    on.
  • Items left for an open PR, each with its PR link, so someone can triage them after the PR merges.
  • Items still marked unsure, with what the check could not settle.
  • If anything was committed, hint that the user can git log --oneline to review or
    git reset HEAD~<n> to undo a contiguous batch.

Acceptance criteria

  • Every decided item was checked against a freshly fetched origin/main first, and its
    recommendation was shown with that evidence.
  • Every promotion to a new task or milestone rests on quoted current lines from main that show the
    problem still exists.
  • Every promoted backlog file has state: promoted/<entity> AND a canonical result: wikilink
    ([[T-<id>]] or [[T-<id>-<slug>]] for a task, [[M-<id>]] or [[M-<id>-<slug>]] for a
    milestone — never a date-prefixed basename) AND last_reviewed: set to today. The wikilink
    names a new artifact or existing work that covers the item.
  • When the check found moved paths, renamed things, or shipped parts, the new task or milestone
    body records the current scope in place of the template placeholders it filled.
  • Every closed/duplicate backlog file has state: closed/duplicate AND
    result: [[<other-backlog-basename>]] AND last_reviewed: set to today, and the referenced
    backlog exists.
  • Every closed/abandoned backlog file has state: closed/abandoned AND last_reviewed: set to
    today, and NO result: field.
  • Every closed/delivered backlog file has state: closed/delivered AND a non-empty result: (a PR
    link or short note of what shipped) AND last_reviewed: set to today.
  • Every modified backlog file passes sdlc entities validate.
  • Every promotion to a new artifact produced exactly one new artifact file AND one updated backlog
    file, both in the same commit. Every promotion to existing work and every close updated exactly
    one backlog file in its commit.
  • Deferred backlog files have only their last_reviewed: changed.
  • Items left for an open PR, and files left unprocessed by the session, are byte-identical to their
    pre-skill state, and the wrap-up names the PR for each item left for one.
  • No file outside docs/planning/ was modified.

Notes

  • Promotion does NOT invoke the interactive $sdlc:task-new or $sdlc:milestone-new skills — they
    ask too many follow-up questions for a triage flow. The new artifact lands with sensible defaults
    (task: state: planning/draft, impact: medium, complexity: medium; milestone:
    state: open/draft, no version or target_date). The author refines them later via
    $sdlc:task-define and $sdlc:milestone-new follow-up edits.
  • The backlog body is NOT copied into the new task/milestone. The body stays in place on the
    backlog file as the origin story. The new artifact carries only the current scope from step 2,
    when the check changed it; follow up with $sdlc:task-define for the rest.
  • This skill never changes the body of a backlog file — only frontmatter. Strike-through / deletion
    of promoted items inside multi-item dump files is the author's job.
  • Backlog files with state: already set are NOT re-triaged. To re-promote (e.g. you decide the
    task it pointed at should actually be a milestone), edit the backlog frontmatter manually —
    clearing state and result returns the file to the untriaged set.
  • Committing model-generated messages. Each decision's commit is authored through
    sdlc commit create (--subject/--body, or --message - for whole messages on stdin) so the
    conventional-commit parens never reach a re-parsing shell. See
    ${CLAUDE_PLUGIN_ROOT}/conventions/commit-messages.md — the op is the canonical pattern; the
    mktemp + quoted-heredoc + git commit -F form stays documented there only as the
    no-CLI-available fallback. Stage the exact paths with git add <paths> first — commit create
    commits the staged index, it does not narrow scope.