Skip to content

Feature: group-aware label matching (LabelGroup/leafLabel) #1107

@van4oza

Description

@van4oza

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions