You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
/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/labels-config.mjs:35getDefaultLabels() 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(constkof['needs-triage',/* … */'rejected']){assert.ok(kinlabels.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 /qawrite 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.
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.
What is wrong
/setupowns 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./setupnever conductsthat part of the conversation, so
classification.labelslands from a default that assumes thetracker already carries all seventeen names.
Evidence
commands/setup.mdcontains zero occurrences oflabelorclassification. The conversationnever raises the subject.
lib/config-schema.mjs:82seedsclassification: { labels: getDefaultLabels('') }.lib/labels-config.mjs:35getDefaultLabels()returns identity maps — the Canonical role isthe tracker string. Its
_trackerparameter is never read.commands/*.mdandlib/*.mjs.commands/triage.md:99readsLABELSfromclassification.labels, and Step 2 instructs theMethod to resolve every Canonical role through it.
Failure mode
A team whose tracker has no
needs-specslabel runs/triage./triagecomposesTRACKER.accessand 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.mjsasserts that every expected name is present. It never asserts thatnothing extra is present:
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:andlocal-markdown:as if the tracker changed the result. Itdoes not;
getDefaultLabelsignores 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,/ticketsand/qawrite states; nothingselects 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.
A re-run already preserves a hand-edited mapping.
mergeConfigisDEFAULTS < existing < answers(
lib/config-schema.mjs:170), soexistingbeats the defaults on every path. The original AC aboutsilent overwriting was already true; it needs a regression test, not a mechanism.
Reference point.
setup-matt-pocock-skillssolves the same problem with zero code — verified,the vendored bundle contains no
.mjs,.js,.py,.tsor.shfile. Its mapping is a markdowntable, 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.labelsin YAML" and never said how a team gets there.D1 —
/setupasks one question, grouped by tierThe seventeen roles are shown as three short tables —
state,type,priority— each with aone-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 inlib/.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 —
/setupnever inspects the tracker and never creates a labelNo 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 thetracker stores a role.
classification.labelsstays a plainRecord<role, string>and this issuedoes not touch the schema's shape.
D4 — No default is seeded
defaultConfig()emits noclassification.labels;classification.labelsjoinsMANDATORY_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_trackerparameter and, with the seed gone, its last caller.The function is deleted.
STATE_LABELS,TYPE_LABELSandPRIORITY_LABELSstay as frozen literalarrays — 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.labelsmissing a shipped Canonical role is a fault.validateConfigreports it, sothe config reads
partialand/setupcollects it;toYamlrefuses to write it. An extra key isallowed and ignored — nothing reads it, and
migrateLegacypreserves a hand-added type such asreleaseon purpose — the sentence is atlib/config-schema.mjs:186, the behaviour it describes at:217.This is not policing the Consumer. The only way a missing key reaches
toYamlis a/setupbug, andthe check makes an older config self-heal: a Consumer whose config predates a role reads as
partialand gets asked, with no migration code.
D6 — No drift mechanism, and no migration for an already-seeded config
classification.labelsgets nosha256entry in.archon/unic-dlc.install.json. #295 D1 draws theline: 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
/setupcarries 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
toYamlstaysstringifyYaml(config). Emitting per-tier comments is possible (it needs ayamlDocument, sincemergeConfigworks on plain objects and drops comments on a re-run), butre-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
/setupasks one label question, presenting the roles as three tier-grouped tables with aone-line gloss each, and writes
classification.labelsfrom the answer. On "keep these" itwrites all seventeen entries explicitly.
/setupdoes not probe the tracker for labels, does not create one, and prints nothing aboutlabels in its Step 8 summary.
defaultConfig()emits noclassification.labelskey.MANDATORY_PATHScontainsclassification.labels, so a config without one reads aspartialand
/setupcollects it.validateConfigreports aclassification.labelsmissing any role inSTATE_LABELS,TYPE_LABELSorPRIORITY_LABELS.toYamlrefuses to write it.validateConfigaccepts aclassification.labelscarrying an extra key. A legacy configwith a hand-added
releasetype still migrates and still writes.partialand is collected, never refused attoYaml. This is the trap in the criterion above. Today a legacy.jsonshort of a role issilently backfilled by
defaultConfig()'s seed beforetoYamlsees it; deleting that seedremoves 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 oftoYaml. A test covers alegacy config missing exactly one role and asserts it reaches
/setup's question rather than anerror.
test/config-schema.test.mjs'sDOGFOOD_JSONcurrently does, which is why the suite would staygreen while that path is broken. Add a deliberately incomplete fixture.
getDefaultLabelsis deleted.lib/labels-config.mjsexports the three frozen arrays andnothing else.
test/labels-config.test.mjsfreezes the membership of all three arrays withassert.deepEqualagainst a literal list, replacing the per-name presence loop. A new Canonicalrole then fails CI until someone changes the list on purpose.
mergeConfigkeeps a hand-editedclassification.labelsfromexistingwhenanswerscarries none. This behaviour already holds (lib/config-schema.mjs:170);the criterion is satisfied by writing the guard, not by changing anything.
LABELS. Vacuous today — no Box reads a stateLabel 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, anddocs/boxes/unic-dlc-qa.md:43,81anddocs/boxes/unic-dlc-explore.md:56,106carryclassification.labelsreferences.that sentence in
CONTEXT.md, and it is there" — butCONTEXT.mdlives in the Plugin and isnever installed; Step 7 writes only the
CLAUDE.mdmarker block. Either the block or theconfig's own surroundings must point somewhere, or an operator meets seventeen unexplained
lines with no thread to pull.
.archon/unic-dlc.config.yamlgains no comments.toYamlremains a singlestringifyYamlcall.
/unic-archon-dlc:setup reconfigureis how a team reviews it.Out of scope
resolvedandcloseda writer, and whether any Box should read a state — bug(unic-archon-dlc): no Box advances an issue past ready-for-agent, so resolved and closed have no writer #332.Record<role, string>— D3 closes it; the tier is the axis.CLAUDE.mdblock — refactor(unic-archon-dlc): /setup Step 7 should tell the truth about what it owns #296. D6 declines to share its mechanism.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.