Skip to content

fix(unic-archon-dlc): /setup never asks for the tracker's labels, so classification.labels ships as an untested guess #329

Description

@orioltf

What is wrong

/setup owns the label mapping. It never asks about it.

Every Box resolves a Canonical role to the tracker's Label string through LABELS
(classification.labels). The team defines those strings during /setup. /setup never conducts
that part of the conversation, so classification.labels lands from a default that assumes the
tracker already carries all seventeen names.

Evidence

  • commands/setup.md contains zero occurrences of label or classification. The conversation
    never raises the subject.
  • lib/config-schema.mjs:82 seeds classification: { labels: getDefaultLabels('') }.
  • lib/labels-config.mjs:35 getDefaultLabels() returns identity maps — the Canonical role is
    the tracker string. Its _tracker parameter is never read.
  • Nothing creates a missing label and nothing verifies one exists, across commands/*.md and
    lib/*.mjs.
  • commands/triage.md:99 reads LABELS from classification.labels, and Step 2 instructs the
    Method to resolve every Canonical role through it.

Failure mode

A team whose tracker has no needs-specs label runs /triage. /triage composes TRACKER.access
and applies a label that does not exist. The error surfaces inside the composed System-skill, far
from its cause, and the operator has no reason to suspect a config they were never asked about.

The same holds for any of the seventeen names. Identity mapping makes the default correct only for a
tracker that already matches the plugin's own vocabulary — which is this monorepo, and nothing else.

The presence check that lets a role in unnoticed

test/labels-config.test.mjs asserts that every expected name is present. It never asserts that
nothing extra is present:

for (const k of ['needs-triage', /* … */ 'rejected']) {
	assert.ok(k in labels.state, `state.${k} should be present`)   // passes with 9 roles. Or 20.
}

An agent adds a ninth state and CI stays green. A Canonical role is a protocol between Boxes — adding
one is a shipped behaviour change, and it should read as one in a diff.

The same file names two tests github: and local-markdown: as if the tracker changed the result. It
does not; getDefaultLabels ignores its argument.

What the grilling found

Two facts, both verified against the shipped source on 2026-08-11, that the original body did not
carry.

No Box reads a state Label string. /triage, /tickets and /qa write states; nothing
selects work by label, and the handoff between Boxes is the Slug a human passes. A missing key
therefore breaks a writer, not a reader — which is this issue's failure mode, and it needs no
reader to bite. Whether the reads should exist is #332.

Corrected 2026-08-12. This paragraph originally rested on a grep of
.archon/workflows/unic-dlc-build.yaml alone, which does not carry the claim.
.archon/workflows/unic-dlc-qa.yaml:277 and .archon/workflows/unic-dlc-explore.yaml:424 both name
state and type roles resolved through classification.labels. Both are writers, which is what the
paragraph argues, so the conclusion stands — but a one-file grep never supported it.

A re-run already preserves a hand-edited mapping. mergeConfig is DEFAULTS < existing < answers
(lib/config-schema.mjs:170), so existing beats the defaults on every path. The original AC about
silent overwriting was already true; it needs a regression test, not a mechanism.

Reference point. setup-matt-pocock-skills solves the same problem with zero code — verified,
the vendored bundle contains no .mjs, .js, .py, .ts or .sh file. Its mapping is a markdown
table, written at setup, and its whole conversation is one question: "Do you want to keep the default
triage labels? (recommended: yes)". It never probes, never creates, never validates.

Decisions

D1-D5 are recorded in the 2026-08-11 amendment to
ADR-0024
, which owned
the sentence "Teams override classification.labels in YAML" and never said how a team gets there.

Corrected 2026-08-12. The amendment carries D1-D5 only. D6 and D7 below are not in
ADR-0024
and were never written there. Either add them to the amendment as part of this work, or
treat them as decisions of record on this issue alone — but do not cite the ADR for them.

D1 — /setup asks one question, grouped by tier

The seventeen roles are shown as three short tables — state, type, priority — each with a
one-line gloss, offering the names the Plugin ships. "Keep these? (recommended: yes)". Overrides are
collected only on no. The gloss is conversation copy in commands/setup.md, never data in lib/.

Seventeen separate questions is unusable, and three (one per tier) buys nothing: a team renaming one
role is already saying no and will edit that row.

D2 — /setup never inspects the tracker and never creates a label

No probe, no gap report, no offer to add. A probe can only tell an operator that a string is absent
from their own tracker, which they know. The right answer to a tracker with different vocabulary is
to map the role onto a string that tracker already carries, not to add a seventeenth label to
someone else's board.

Consequently Step 8 prints nothing about labels. A line saying "I did not do a thing I never claimed
to do" is noise in a summary that already carries config path, tracker, gates, methods and overrides.
(Since #294, Step 8 also carries the Archon remote and the workflows written and removed — the list
above is short by three. The decision is unaffected; the summary is simply fuller than described.)
The reader is owed that sentence in CONTEXT.md, and it is there.

D3 — The tier carries the axis, so the label-shaped model needs no generalisation

A Box hands the composed tracker skill the tier alongside the string, so the skill knows whether to
write an Azure DevOps System.State, a Jira status or a GitHub label. No Box ever learns how the
tracker stores a role. classification.labels stays a plain Record<role, string> and this issue
does not touch the schema's shape.

D4 — No default is seeded

Consequence noted 2026-08-12, so nobody reopens D4 mid-implementation. Deleting
getDefaultLabels moves construction of the identity map out of tested lib/ and into the
markdown prompt in commands/setup.md, which no test executes. On the "keep these" path the
Consumer still receives exactly the map that ships today. D4's goal is met by consent — a human
accepted it — not by different content. That is the intended outcome; it is only surprising if
you expected the map itself to change.

defaultConfig() emits no classification.labels; classification.labels joins MANDATORY_PATHS.
The seed is what manufactures the untested guess in this issue's title, so the fix is to delete it
rather than to validate around it. A mapping is then present because a human accepted it, or absent
and collected on the next run.

getDefaultLabels() loses its unread _tracker parameter and, with the seed gone, its last caller.
The function is deleted. STATE_LABELS, TYPE_LABELS and PRIORITY_LABELS stay as frozen literal
arrays — a constant, not a computation — because the role list is the protocol between four Boxes and
needs one place to diff.

D5 — The key set is closed downward

A classification.labels missing a shipped Canonical role is a fault. validateConfig reports it, so
the config reads partial and /setup collects it; toYaml refuses to write it. An extra key is
allowed and ignored — nothing reads it, and migrateLegacy preserves a hand-added type such as
release on purpose — the sentence is at lib/config-schema.mjs:186, the behaviour it describes at :217.

This is not policing the Consumer. The only way a missing key reaches toYaml is a /setup bug, and
the check makes an older config self-heal: a Consumer whose config predates a role reads as partial
and gets asked, with no migration code.

D6 — No drift mechanism, and no migration for an already-seeded config

classification.labels gets no sha256 entry in .archon/unic-dlc.install.json. #295 D1 draws the
line: generated files get hashes, the config is hand-editable by design. Hashing a file the operator
is invited to edit inverts that. This answers the original open question 3 — #296 and this issue
share no mechanism
.

A config written by an older /setup carries the seeded map on disk, present and non-null,
indistinguishable from an accepted answer. That boundary is accepted: new installs are clean, existing
ones keep their map, and the CHANGELOG says so. Treating an identity map as unanswered would misfire
on every team that genuinely accepted identity; stripping it would discard a mapping a team may have
hand-edited.

D7 — The written YAML carries no comments

toYaml stays stringifyYaml(config). Emitting per-tier comments is possible (it needs a yaml
Document, since mergeConfig works on plain objects and drops comments on a re-run), but
re-stamping them on every write fights an operator who adds their own and churns the diff. The config
is meant to be hand-edited; a generator that rewrites its comments is friction.

Acceptance criteria

  • /setup asks one label question, presenting the roles as three tier-grouped tables with a
    one-line gloss each, and writes classification.labels from the answer. On "keep these" it
    writes all seventeen entries explicitly.
  • /setup does not probe the tracker for labels, does not create one, and prints nothing about
    labels in its Step 8 summary.
  • defaultConfig() emits no classification.labels key.
  • MANDATORY_PATHS contains classification.labels, so a config without one reads as partial
    and /setup collects it.
  • validateConfig reports a classification.labels missing any role in STATE_LABELS,
    TYPE_LABELS or PRIORITY_LABELS. toYaml refuses to write it.
  • validateConfig accepts a classification.labels carrying an extra key. A legacy config
    with a hand-added release type still migrates and still writes.
  • A legacy config MISSING a role reads as partial and is collected, never refused at
    toYaml.
    This is the trap in the criterion above. Today a legacy .json short of a role is
    silently backfilled by defaultConfig()'s seed before toYaml sees it; deleting that seed
    removes the backfill, so the same config now hits the closed-key check and fails to write.
    The escape route is D5's partial → collect path, not a relaxation of toYaml. A test covers a
    legacy config missing exactly one role and asserts it reaches /setup's question rather than an
    error.
  • The fixture guarding the criterion above does not carry all seventeen roles.
    test/config-schema.test.mjs's DOGFOOD_JSON currently does, which is why the suite would stay
    green while that path is broken. Add a deliberately incomplete fixture.
  • getDefaultLabels is deleted. lib/labels-config.mjs exports the three frozen arrays and
    nothing else.
  • test/labels-config.test.mjs freezes the membership of all three arrays with
    assert.deepEqual against a literal list, replacing the per-name presence loop. A new Canonical
    role then fails CI until someone changes the list on purpose.
  • No test name implies the mapping varies by tracker.
  • A regression test asserts mergeConfig keeps a hand-edited classification.labels from
    existing when answers carries none. This behaviour already holds (lib/config-schema.mjs:170);
    the criterion is satisfied by writing the guard, not by changing anything.
  • No Box reads a Label string from anywhere but LABELS. Vacuous today — no Box reads a state
    Label string at all, so this passes whether or not the fix is correct. Verify it against
    docs/boxes/, not .archon/commands/: fix(unic-archon-dlc): /setup never installs the Archon artefacts, so no Consumer can run an Archon Box #294 moved every Box operator doc there, and
    docs/boxes/unic-dlc-qa.md:43,81 and docs/boxes/unic-dlc-explore.md:56,106 carry
    classification.labels references.
  • The Consumer can find out what the seventeen written lines mean. D2 says "the reader is owed
    that sentence in CONTEXT.md, and it is there" — but CONTEXT.md lives in the Plugin and is
    never installed; Step 7 writes only the CLAUDE.md marker block. Either the block or the
    config's own surroundings must point somewhere, or an operator meets seventeen unexplained
    lines with no thread to pull.
  • .archon/unic-dlc.config.yaml gains no comments. toYaml remains a single stringifyYaml
    call.
  • The CHANGELOG states that an existing config keeps its seeded mapping, and that
    /unic-archon-dlc:setup reconfigure is how a team reviews it.

Out of scope

Context

Found on 2026-08-11 while grilling #309, which covers the provider-agnosticism barrier and not this.
Charted by #315. Grilled 2026-08-11; the decisions above are recorded in ADR-0024's amendment of the
same date.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions