Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-triage-label-slug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@bradygaster/squad-sdk': patch
'@bradygaster/squad-cli': patch
---

Fix triage label slug derivation: use `slugify()` instead of `toLowerCase()` so multi-word agent names produce valid GitHub labels (e.g. "Steve Rogers" → `squad:steve-rogers` instead of `squad:steve rogers`).

Pre-create all `squad:{member}` labels at watch startup via `ensureTag()` so `gh issue edit --add-label` never fails on missing labels.
6 changes: 4 additions & 2 deletions .github/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ jobs:
return;
}

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
`- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\``
).join('\n');

// Determine best assignee based on issue content and routing
Expand Down Expand Up @@ -214,7 +216,7 @@ jobs:
}

const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`;

// Add the member-specific label
await github.rest.issues.addLabels({
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/sync-squad-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];

for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
name: `squad:${slugify(member.name)}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
Expand Down
4 changes: 3 additions & 1 deletion .squad-templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function normalizeEol(content) {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

function parseRoutingRules(routingMd) {
const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i);
if (!table) return [];
Expand Down Expand Up @@ -124,7 +126,7 @@ function parseRoster(teamMd) {
members.push({
name,
role,
label: `squad:${name.toLowerCase()}`,
label: `squad:${slugify(name)}`,
});
}

Expand Down
6 changes: 4 additions & 2 deletions .squad-templates/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ jobs:
return;
}

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
`- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\``
).join('\n');

// Determine best assignee based on issue content and routing
Expand Down Expand Up @@ -189,7 +191,7 @@ jobs:
}

const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`;

// Add the member-specific label
await github.rest.issues.addLabels({
Expand Down
4 changes: 3 additions & 1 deletion .squad-templates/workflows/sync-squad-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];

for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
name: `squad:${slugify(member.name)}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
Expand Down
4 changes: 3 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ if (cmd === 'watch') {

const content = fs.readFileSync(teamMd, 'utf8');

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Parse members from roster
function parseMembers(text) {
const lines = text.split('\n');
Expand All @@ -283,7 +285,7 @@ if (cmd === 'watch') {
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) {
members.push({ name: cells[0], role: cells[1], label: `squad:${cells[0].toLowerCase()}` });
members.push({ name: cells[0], role: cells[1], label: `squad:${slugify(cells[0])}` });
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,19 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
fatal('No squad members found in team.md');
}

// Pre-create squad member labels so addTag never fails on missing labels
if (adapter.ensureTag) {
for (const member of roster) {
try {
await adapter.ensureTag(member.label, { color: 'd4c5f9', description: `Squad triage: ${member.name}` });
} catch { /* best-effort — continue if label creation fails */ }
}
try {
await adapter.ensureTag('squad:copilot', { color: 'd4c5f9', description: 'Squad triage: Copilot coding agent' });
} catch { /* best-effort */ }
console.log(`${DIM}Labels: ensured ${roster.length + 1} squad labels exist${RESET}`);
Comment on lines +667 to +675

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup message says labels were “ensured … exist”, but label creation is best-effort and both ensureTag() and this caller swallow errors. This can produce a misleading success message even when none of the labels were created. Consider tracking successes and reporting “attempted to ensure” (or only printing the message when all creations succeed / when verbose is enabled).

Suggested change
for (const member of roster) {
try {
await adapter.ensureTag(member.label, { color: 'd4c5f9', description: `Squad triage: ${member.name}` });
} catch { /* best-effort — continue if label creation fails */ }
}
try {
await adapter.ensureTag('squad:copilot', { color: 'd4c5f9', description: 'Squad triage: Copilot coding agent' });
} catch { /* best-effort */ }
console.log(`${DIM}Labels: ensured ${roster.length + 1} squad labels exist${RESET}`);
const totalSquadLabels = roster.length + 1;
let ensuredSquadLabels = 0;
for (const member of roster) {
try {
await adapter.ensureTag(member.label, { color: 'd4c5f9', description: `Squad triage: ${member.name}` });
ensuredSquadLabels += 1;
} catch { /* best-effort — continue if label creation fails */ }
}
try {
await adapter.ensureTag('squad:copilot', { color: 'd4c5f9', description: 'Squad triage: Copilot coding agent' });
ensuredSquadLabels += 1;
} catch { /* best-effort */ }
if (ensuredSquadLabels === totalSquadLabels) {
console.log(`${DIM}Labels: ensured ${totalSquadLabels} squad labels exist${RESET}`);
} else {
console.log(`${DIM}Labels: attempted to ensure ${totalSquadLabels} squad labels exist (${ensuredSquadLabels} succeeded)${RESET}`);
}

Copilot uses AI. Check for mistakes.
}

const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
const monitorSessionId = 'ralph-watch';
Expand Down
4 changes: 3 additions & 1 deletion packages/squad-cli/templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function normalizeEol(content) {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

function parseRoutingRules(routingMd) {
const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i);
if (!table) return [];
Expand Down Expand Up @@ -124,7 +126,7 @@ function parseRoster(teamMd) {
members.push({
name,
role,
label: `squad:${name.toLowerCase()}`,
label: `squad:${slugify(name)}`,
});
}

Expand Down
6 changes: 4 additions & 2 deletions packages/squad-cli/templates/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ jobs:
return;
}

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
`- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\``
).join('\n');

// Determine best assignee based on issue content and routing
Expand Down Expand Up @@ -189,7 +191,7 @@ jobs:
}

const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`;

// Add the member-specific label
await github.rest.issues.addLabels({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];

for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
name: `squad:${slugify(member.name)}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
Expand Down
11 changes: 11 additions & 0 deletions packages/squad-sdk/src/platform/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ export class GitHubAdapter implements PlatformAdapter {
this.gh(['issue', 'edit', String(workItemId), '--repo', this.repoFlag, '--add-label', tag]);
}

async ensureTag(tag: string, options?: { color?: string; description?: string }): Promise<void> {
const args = ['label', 'create', tag, '--repo', this.repoFlag, '--force'];
if (options?.color) args.push('--color', options.color);
if (options?.description) args.push('--description', options.description);
try {
this.gh(args);
} catch {
// Label already exists or creation failed — continue either way
}
Comment on lines +135 to +139

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureTag() swallows all gh label create failures. Since the command is run with --force, “already exists” shouldn’t be an error, so this catch is likely to hide real problems (e.g., missing repo scope / insufficient permissions), and watch will later fail to add labels with little diagnostic info. Consider only ignoring the specific “already exists” case (if needed) or rethrowing/logging unexpected errors so callers can surface them (at least in verbose mode).

Suggested change
try {
this.gh(args);
} catch {
// Label already exists or creation failed — continue either way
}
this.gh(args);

Copilot uses AI. Check for mistakes.
}

async removeTag(workItemId: number, tag: string): Promise<void> {
this.gh(['issue', 'edit', String(workItemId), '--repo', this.repoFlag, '--remove-label', tag]);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/squad-sdk/src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export interface PlatformAdapter {
removeTag(workItemId: number, tag: string): Promise<void>;
addComment(workItemId: number, comment: string): Promise<void>;

/** Ensure a tag/label exists (creates it if missing). No-op on platforms with auto-created tags. */
ensureTag?(tag: string, options?: { color?: string; description?: string }): Promise<void>;

// Pull Requests
listPullRequests(options: { status?: string; limit?: number }): Promise<PullRequest[]>;
createPullRequest(options: {
Expand Down
5 changes: 4 additions & 1 deletion packages/squad-sdk/src/ralph/triage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { normalizeEol } from '../utils/normalize-eol.js';
import { slugify } from '../utils/slugify.js';

export { slugify } from '../utils/slugify.js';

Comment on lines +4 to 5

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module now re-exports slugify from utils/slugify, which expands the public surface of ralph/triage beyond triage concerns. If the intent is to make slugify generally available, consider exporting it from a more appropriate public location (e.g., a utils/barrel) rather than from the triage module to avoid confusing API organization.

Suggested change
export { slugify } from '../utils/slugify.js';

Copilot uses AI. Check for mistakes.
/** Parsed routing rule from routing.md */
export interface RoutingRule {
Expand Down Expand Up @@ -118,7 +121,7 @@ export function parseRoster(teamMd: string): TeamMember[] {
members.push({
name,
role,
label: `squad:${name.toLowerCase()}`,
label: `squad:${slugify(name)}`,
});
Comment on lines 121 to 125

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseRoster() now derives label via slugify(name), but the TeamMember.label field doc comment still describes it as squad:{lowercase name}. Since this function defines the canonical label format, the type-level documentation should be updated to match the slugified behavior to avoid misleading SDK consumers.

Copilot uses AI. Check for mistakes.
}

Expand Down
15 changes: 15 additions & 0 deletions packages/squad-sdk/src/utils/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Convert a display name to a URL/label-safe slug.
*
* "Steve Rogers" → "steve-rogers"
* "Tony Stark (Iron Man)" → "tony-stark-iron-man"
* "Doctor Strange (Stephen Strange)" → "doctor-strange-stephen-strange"
* "Thor" → "thor"
* "already-slugified" → "already-slugified"
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
Comment on lines +1 to +15

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are already multiple identical private slugify() implementations in the SDK (e.g., under src/marketplace/*). Introducing another canonical implementation is fine, but it increases duplication unless the existing ones are migrated to import this utility. Consider either reusing an existing shared slugify helper (if one is intended) or planning a follow-up to deduplicate so behavior can’t drift across features.

Copilot uses AI. Check for mistakes.
4 changes: 3 additions & 1 deletion packages/squad-sdk/templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function normalizeEol(content) {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

function parseRoutingRules(routingMd) {
const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i);
if (!table) return [];
Expand Down Expand Up @@ -124,7 +126,7 @@ function parseRoster(teamMd) {
members.push({
name,
role,
label: `squad:${name.toLowerCase()}`,
label: `squad:${slugify(name)}`,
});
}

Expand Down
6 changes: 4 additions & 2 deletions packages/squad-sdk/templates/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ jobs:
return;
}

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
`- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\``
).join('\n');

// Determine best assignee based on issue content and routing
Expand Down Expand Up @@ -189,7 +191,7 @@ jobs:
}

const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`;

// Add the member-specific label
await github.rest.issues.addLabels({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];

for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
name: `squad:${slugify(member.name)}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
Expand Down
4 changes: 3 additions & 1 deletion templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function normalizeEol(content) {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

function parseRoutingRules(routingMd) {
const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i);
if (!table) return [];
Expand Down Expand Up @@ -124,7 +126,7 @@ function parseRoster(teamMd) {
members.push({
name,
role,
label: `squad:${name.toLowerCase()}`,
label: `squad:${slugify(name)}`,
});
}

Expand Down
6 changes: 4 additions & 2 deletions templates/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ jobs:
return;
}

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
`- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\``
).join('\n');

// Determine best assignee based on issue content and routing
Expand Down Expand Up @@ -189,7 +191,7 @@ jobs:
}

const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`;

// Add the member-specific label
await github.rest.issues.addLabels({
Expand Down
4 changes: 3 additions & 1 deletion templates/workflows/sync-squad-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];

for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
name: `squad:${slugify(member.name)}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
Expand Down
Loading
Loading