Summary
Linear lets users organize labels into label groups (hierarchical parent/child labels, where only leaf labels are applied to issues). Cyrus currently throws away the group relationship when reading labels from the Linear SDK — it only looks at label.name — so there's no way to use groups for disambiguation or for tidier workspace organization of cyrus-specific control labels.
Current behavior
Both PromptBuilder.fetchIssueLabels and GitService.fetchIssueLabels flatten labels to leaf names:
async fetchIssueLabels(issue) {
try {
const labels = await issue.labels();
return labels.nodes.map((label) => label.name);
} catch (error) { ... }
}
This loses the parent/group relationship that IssueLabel.parent (or the already-populated IssueLabel.parentId) exposes.
Why this matters
Users naturally want to organize cyrus-specific control labels (runner selection, model, effort, routing) into groups in Linear's UI for visual cleanliness. E.g.:
📁 Agent 📁 Model 📁 ClaudeEffort 📁 Cyrus Routing
├─ claude ├─ opus ├─ low ├─ ai-diary
├─ gemini ├─ sonnet ├─ medium ├─ platform-api
└─ codex └─ haiku ├─ high └─ marketing-site
└─ max
Today this mostly works for opus/sonnet/haiku and claude/gemini/codex because those names are distinctive (bare leaf matches). It falls apart for generic leaf names like low/medium/high/max (used by the effort feature requested in CYPACK-1083) because a bare max would collide with any other max label in the workspace (priority, size, etc.).
Related constraint: Linear reserves certain label group names. Notably effort is reserved — trying to create a label group literally named Effort fails with The label name \"effort\" is reserved. (Linear appears to be reserving this name for its own future use.) So any effort grouping must use an alternate name like ClaudeEffort, AgentEffort, Effort Level, Cyrus Effort, etc. This forces the matcher to be flexible rather than matching a single fixed group name.
Proposed change
1. Enrich fetchIssueLabels output
For each label, emit both the bare leaf name AND a groupName/leafName synthetic entry when the label has a parent. Short-circuit on parentId to avoid unnecessary GraphQL fetches for ungrouped labels. Wrap parent access in try/catch so a failed parent fetch doesn't break label resolution.
async fetchIssueLabels(issue) {
try {
const labels = await issue.labels();
const out: string[] = [];
for (const label of labels.nodes) {
out.push(label.name);
try {
if (label.parentId) {
const parent = await label.parent;
if (parent?.name) {
out.push(`${parent.name}/${label.name}`);
}
}
} catch {
// Parent fetch failed — keep the leaf name we already pushed.
}
}
return out;
} catch (error) { ... }
}
Existing matchers (resolveAgentFromLabel, resolveModelFromLabel, routing-label matching, system-prompt label routing) keep working unchanged because the bare leaf is still in the array. The enrichment is purely additive.
2. Extend effort matcher to use group context (from CYPACK-1083)
Because effort is a reserved Linear group name, the matcher must accept any group whose name contains effort, not just the literal effort:
const resolveEffortFromLabel = (lc: string[]) => {
// 1) Flat leaf-name forms (highest priority)
for (const lvl of VALID_EFFORTS) {
if (lc.includes(`effort-${lvl}`)) return lvl;
if (lc.includes(`effort=${lvl}`)) return lvl;
if (lc.includes(`${lvl}-effort`)) return lvl;
}
// 2) Group-qualified forms — any group name containing "effort"
for (const label of lc) {
const slashIdx = label.lastIndexOf("/");
if (slashIdx <= 0) continue;
const group = label.slice(0, slashIdx);
const leaf = label.slice(slashIdx + 1);
if (!group.includes("effort")) continue;
if (VALID_EFFORTS.includes(leaf)) return leaf;
}
return undefined;
};
This correctly:
- Matches
ClaudeEffort/max, Effort Level/high, CyrusEffort/low, my-effort-tiers/medium
- Ignores
Priority/max, Reasoning/high, Size/low (group name lacks effort)
- Falls back to the flat
effort-max / max-effort / effort=max forms for users who don't want groups
3. (Optional) Do the same pattern for runner and model
The same group-qualified form could be added to resolveAgentFromLabel and resolveModelFromLabel — e.g. Agent/claude, Model/opus. In most cases this is unnecessary because the bare leaf names don't collide in practice, but accepting them would be consistent and let users organize a single Cyrus Config / AI Config group containing all runner/model/effort sub-labels. Low risk since it's purely additive.
4. (Optional) Repo-config escape hatch for arbitrary group names
For users with strong opinions about group naming (or for future features that don't have an obvious substring anchor), allow an explicit allowlist in the repository config:
{
"cyrusLabelGroups": {
"effort": ["My Effort Tiers", "Thinking Budget"],
"agent": ["AI Agent"],
"model": ["LLM Model"]
}
}
This is nice-to-have, not strictly required for the primary use case.
Testing
I've implemented this locally on top of CYPACK-1083's effort patch and validated with a 14-case unit test covering:
- Flat forms (existing):
effort-max, max-effort, effort=max
- Group forms:
ClaudeEffort/max, Effort Level/high, CyrusEffort/low, my-effort-tiers/medium, Claude Effort/max (with spaces), EFFORTLEVEL/max (caps)
- Negative cases:
Priority/max, Reasoning/high, Size/low correctly return undefined
- Mixed:
Priority/max + AgentEffort/high correctly resolves to high (the AgentEffort group wins over the ignored Priority group)
- Precedence:
[effort=low] description tag beats AgentEffort/max label
- Bare
max alone still returns undefined (safety against generic-word collisions)
All 14 cases pass. Runtime verification: cyrus-edge-worker restarts cleanly with the patch applied, /status returns {"status":"idle"}.
Notes on scope
This is closely related to CYPACK-1083 (effort configuration). Both feature requests share the theme of full configurability of Claude runner knobs across all layers (description tag → label → repository config → global default). Happy to roll both into a single PR or split them, whichever is easier to review. The label-group enrichment is the lower-level enabler; the effort matcher is one of multiple consumers that benefits.
Summary
Linear lets users organize labels into label groups (hierarchical parent/child labels, where only leaf labels are applied to issues). Cyrus currently throws away the group relationship when reading labels from the Linear SDK — it only looks at
label.name— so there's no way to use groups for disambiguation or for tidier workspace organization of cyrus-specific control labels.Current behavior
Both
PromptBuilder.fetchIssueLabelsandGitService.fetchIssueLabelsflatten labels to leaf names:This loses the parent/group relationship that
IssueLabel.parent(or the already-populatedIssueLabel.parentId) exposes.Why this matters
Users naturally want to organize cyrus-specific control labels (runner selection, model, effort, routing) into groups in Linear's UI for visual cleanliness. E.g.:
Today this mostly works for
opus/sonnet/haikuandclaude/gemini/codexbecause those names are distinctive (bare leaf matches). It falls apart for generic leaf names likelow/medium/high/max(used by the effort feature requested in CYPACK-1083) because a baremaxwould collide with any othermaxlabel in the workspace (priority, size, etc.).Related constraint: Linear reserves certain label group names. Notably
effortis reserved — trying to create a label group literally namedEffortfails withThe label name \"effort\" is reserved.(Linear appears to be reserving this name for its own future use.) So any effort grouping must use an alternate name likeClaudeEffort,AgentEffort,Effort Level,Cyrus Effort, etc. This forces the matcher to be flexible rather than matching a single fixed group name.Proposed change
1. Enrich
fetchIssueLabelsoutputFor each label, emit both the bare leaf name AND a
groupName/leafNamesynthetic entry when the label has a parent. Short-circuit onparentIdto avoid unnecessary GraphQL fetches for ungrouped labels. Wrap parent access in try/catch so a failed parent fetch doesn't break label resolution.Existing matchers (
resolveAgentFromLabel,resolveModelFromLabel, routing-label matching, system-prompt label routing) keep working unchanged because the bare leaf is still in the array. The enrichment is purely additive.2. Extend effort matcher to use group context (from CYPACK-1083)
Because
effortis a reserved Linear group name, the matcher must accept any group whose name containseffort, not just the literaleffort:This correctly:
ClaudeEffort/max,Effort Level/high,CyrusEffort/low,my-effort-tiers/mediumPriority/max,Reasoning/high,Size/low(group name lackseffort)effort-max/max-effort/effort=maxforms for users who don't want groups3. (Optional) Do the same pattern for runner and model
The same group-qualified form could be added to
resolveAgentFromLabelandresolveModelFromLabel— e.g.Agent/claude,Model/opus. In most cases this is unnecessary because the bare leaf names don't collide in practice, but accepting them would be consistent and let users organize a singleCyrus Config/AI Configgroup containing all runner/model/effort sub-labels. Low risk since it's purely additive.4. (Optional) Repo-config escape hatch for arbitrary group names
For users with strong opinions about group naming (or for future features that don't have an obvious substring anchor), allow an explicit allowlist in the repository config:
{ "cyrusLabelGroups": { "effort": ["My Effort Tiers", "Thinking Budget"], "agent": ["AI Agent"], "model": ["LLM Model"] } }This is nice-to-have, not strictly required for the primary use case.
Testing
I've implemented this locally on top of CYPACK-1083's effort patch and validated with a 14-case unit test covering:
effort-max,max-effort,effort=maxClaudeEffort/max,Effort Level/high,CyrusEffort/low,my-effort-tiers/medium,Claude Effort/max(with spaces),EFFORTLEVEL/max(caps)Priority/max,Reasoning/high,Size/lowcorrectly returnundefinedPriority/max + AgentEffort/highcorrectly resolves tohigh(theAgentEffortgroup wins over the ignoredPrioritygroup)[effort=low]description tag beatsAgentEffort/maxlabelmaxalone still returnsundefined(safety against generic-word collisions)All 14 cases pass. Runtime verification:
cyrus-edge-workerrestarts cleanly with the patch applied,/statusreturns{"status":"idle"}.Notes on scope
This is closely related to CYPACK-1083 (effort configuration). Both feature requests share the theme of full configurability of Claude runner knobs across all layers (description tag → label → repository config → global default). Happy to roll both into a single PR or split them, whichever is easier to review. The label-group enrichment is the lower-level enabler; the effort matcher is one of multiple consumers that benefits.