@yaawn/workflow 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +172 -2
  2. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +45 -0
  3. package/agents/instructions/yaawn-accessibility.css.instructions.md +58 -0
  4. package/agents/instructions/yaawn-accessibility.html.instructions.md +90 -0
  5. package/agents/instructions/yaawn-accessibility.javascript.instructions.md +50 -0
  6. package/agents/instructions/yaawn-clean-code.typescript.instructions.md +74 -0
  7. package/agents/instructions/yaawn-function-coupling.typescript.instructions.md +100 -0
  8. package/agents/instructions/yaawn-html-css-js.html.instructions.md +32 -0
  9. package/agents/instructions/yaawn-open-source-first.instructions.md +38 -0
  10. package/agents/instructions/yaawn-personas.instructions.md +18 -0
  11. package/agents/instructions/yaawn-semantic-html.html.instructions.md +40 -0
  12. package/agents/instructions/yaawn-vanilla-css.css.instructions.md +50 -0
  13. package/agents/output-styles/yaawn-eli5.instructions.md +45 -0
  14. package/agents/personas/yaawn-developer.md +16 -0
  15. package/agents/personas/yaawn-product-owner.md +18 -0
  16. package/agents/personas/yaawn-reviewer.md +16 -0
  17. package/agents/skills/yaawn-clean-code/SKILL.md +61 -0
  18. package/agents/skills/yaawn-clean-code/references/classes.md +114 -0
  19. package/agents/skills/yaawn-clean-code/references/comments.md +164 -0
  20. package/agents/skills/yaawn-clean-code/references/error-handling.md +151 -0
  21. package/agents/skills/yaawn-clean-code/references/formatting.md +146 -0
  22. package/agents/skills/yaawn-clean-code/references/functions.md +261 -0
  23. package/agents/skills/yaawn-clean-code/references/meaningful-names.md +234 -0
  24. package/agents/skills/yaawn-clean-code/references/objects-and-data-structures.md +130 -0
  25. package/agents/skills/yaawn-clean-code/references/principles.md +76 -0
  26. package/agents/skills/yaawn-clean-code/references/simple-design-and-code-smells.md +130 -0
  27. package/agents/skills/yaawn-clean-code/references/unit-tests.md +114 -0
  28. package/agents/skills/yaawn-create-plan/SKILL.md +228 -0
  29. package/agents/skills/yaawn-grill-bug-report/SKILL.md +31 -0
  30. package/agents/skills/yaawn-grill-bug-report/references/bug-report-checklist.md +27 -0
  31. package/agents/skills/yaawn-grill-design/SKILL.md +68 -0
  32. package/agents/skills/yaawn-grill-design/references/design-checklist.md +36 -0
  33. package/agents/skills/yaawn-grill-other/SKILL.md +31 -0
  34. package/agents/skills/yaawn-grill-other/references/other-checklist.md +19 -0
  35. package/agents/skills/yaawn-grill-refactor/SKILL.md +31 -0
  36. package/agents/skills/yaawn-grill-refactor/references/refactor-checklist.md +26 -0
  37. package/agents/skills/yaawn-grill-spike/SKILL.md +31 -0
  38. package/agents/skills/yaawn-grill-spike/references/spike-checklist.md +25 -0
  39. package/agents/skills/yaawn-grill-story/SKILL.md +34 -0
  40. package/agents/skills/yaawn-grill-story/references/concept-challenge.md +67 -0
  41. package/agents/skills/yaawn-grill-story/references/story-checklist.md +43 -0
  42. package/agents/skills/yaawn-implement-plan/SKILL.md +196 -0
  43. package/agents/skills/yaawn-implement-plan/assets/fix-prompt.md +66 -0
  44. package/agents/skills/yaawn-implement-plan/assets/review-prompt.md +89 -0
  45. package/agents/skills/yaawn-implement-plan/assets/session-prompt.md +80 -0
  46. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +218 -0
  47. package/agents/skills/yaawn-implement-plan/scripts/caffeinate.ts +56 -0
  48. package/agents/skills/yaawn-implement-plan/scripts/groups.ts +233 -0
  49. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +772 -0
  50. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +1176 -0
  51. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +53 -0
  52. package/agents/skills/yaawn-implement-plan/scripts/stages.ts +65 -0
  53. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +381 -0
  54. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +431 -0
  55. package/agents/skills/yaawn-owasp-security/SKILL.md +106 -0
  56. package/agents/skills/yaawn-owasp-security/references/a01-broken-access-control.md +58 -0
  57. package/agents/skills/yaawn-owasp-security/references/a02-security-misconfiguration.md +56 -0
  58. package/agents/skills/yaawn-owasp-security/references/a03-software-supply-chain-failures.md +56 -0
  59. package/agents/skills/yaawn-owasp-security/references/a04-cryptographic-failures.md +67 -0
  60. package/agents/skills/yaawn-owasp-security/references/a05-injection.md +53 -0
  61. package/agents/skills/yaawn-owasp-security/references/a06-insecure-design.md +48 -0
  62. package/agents/skills/yaawn-owasp-security/references/a07-authentication-failures.md +68 -0
  63. package/agents/skills/yaawn-owasp-security/references/a08-software-data-integrity-failures.md +45 -0
  64. package/agents/skills/yaawn-owasp-security/references/a09-security-logging-alerting-failures.md +59 -0
  65. package/agents/skills/yaawn-owasp-security/references/a10-mishandling-exceptional-conditions.md +63 -0
  66. package/agents/skills/yaawn-prepare-plan/SKILL.md +110 -0
  67. package/agents/skills/yaawn-save-plan/SKILL.md +65 -0
  68. package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +331 -0
  69. package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +443 -0
  70. package/agents/skills/yaawn-split-story/SKILL.md +189 -0
  71. package/agents/skills/yaawn-split-story/references/spider.md +64 -0
  72. package/agents/skills/yaawn-split-story/scripts/create-issue.ts +162 -0
  73. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +154 -0
  74. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +362 -0
  75. package/agents/skills/yaawn-verify-executed-plan/scripts/pull-request.ts +131 -0
  76. package/agents/skills/yaawn-write-bug-report/SKILL.md +87 -0
  77. package/agents/skills/yaawn-write-other/SKILL.md +90 -0
  78. package/agents/skills/yaawn-write-refactor/SKILL.md +94 -0
  79. package/agents/skills/yaawn-write-spike/SKILL.md +94 -0
  80. package/agents/skills/yaawn-write-user-story/SKILL.md +187 -0
  81. package/agents/skills/yaawn-write-user-story/references/acceptance-criteria.md +186 -0
  82. package/agents/skills/yaawn-write-user-story/references/five-types-of-value.md +82 -0
  83. package/agents/skills/yaawn-write-user-story/references/user-story-fundamentals.md +75 -0
  84. package/agents/yaawn-glossary.md +146 -0
  85. package/agents/yaawn-grilling.md +75 -0
  86. package/agents/yaawn-worktrees.md +69 -0
  87. package/config/install-tasks.json +35 -0
  88. package/config/settings.json +23 -0
  89. package/config/wizard.json +128 -0
  90. package/github/ISSUE_TEMPLATE/yaawn-bug-report.md +59 -0
  91. package/github/ISSUE_TEMPLATE/yaawn-other.md +38 -0
  92. package/github/ISSUE_TEMPLATE/yaawn-refactor.md +38 -0
  93. package/github/ISSUE_TEMPLATE/yaawn-spike.md +16 -0
  94. package/github/ISSUE_TEMPLATE/yaawn-story.md +56 -0
  95. package/package.json +33 -4
  96. package/scripts/install.js +73 -0
  97. package/yaawn/gitignore +1 -0
  98. package/yaawn/yaawn-definition-of-done.md +5 -0
  99. package/yaawn/yaawn-project-glossary.md +7 -0
  100. package/yaawn/yaawn-user-roles.md +3 -0
  101. package/yaawn/yaawn-verification-commands.md +7 -0
@@ -0,0 +1,218 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { succeeds } from "./targets.js";
3
+
4
+ // The coding agent CLI that runs the sessions. The binary only ever comes
5
+ // from this fixed allow-list — never from the settings file or a flag as
6
+ // free text — and is spawned with an argument array, never through a shell.
7
+
8
+ const agentNames = ["copilot", "claude", "vibe"] as const;
9
+
10
+ export type AgentName = (typeof agentNames)[number];
11
+
12
+ export type AgentChoice =
13
+ { ok: true; agent: AgentName } | { ok: false; failure: string };
14
+
15
+ export type SessionKind = "implement" | "review" | "fix";
16
+
17
+ export interface AgentSession {
18
+ kind: SessionKind;
19
+ prompt: string;
20
+ name: string;
21
+ transcriptPath: string;
22
+ }
23
+
24
+ export interface AgentCommand {
25
+ binary: AgentName;
26
+ args: string[];
27
+ // Claude and Vibe have no --share: the caller writes their stdout to the
28
+ // transcript.
29
+ transcriptFromStdout: boolean;
30
+ }
31
+
32
+ export interface AgentArguments {
33
+ agentOption: string | null;
34
+ rest: string[];
35
+ }
36
+
37
+ export const settingsPath = ".yaawn/yaawn-settings.json";
38
+ const defaultAgent: AgentName = "copilot";
39
+ const agentFlag = "--agent";
40
+ const maxShownValueLength = 40;
41
+ const copilotEffort = "medium";
42
+
43
+ // The reviewer runs on a different model than the implementer.
44
+ const copilotModels: Record<SessionKind, string> = {
45
+ implement: "claude-opus-5.5",
46
+ review: "gpt-5.3-codex",
47
+ fix: "claude-opus-5.5",
48
+ };
49
+
50
+ const claudeModels: Record<SessionKind, string> = {
51
+ implement: "opus",
52
+ review: "sonnet",
53
+ fix: "opus",
54
+ };
55
+
56
+ const transcriptExtensions: Record<AgentName, string> = {
57
+ copilot: ".md",
58
+ claude: ".jsonl",
59
+ vibe: ".jsonl",
60
+ };
61
+
62
+ function isAgentName(value: unknown): value is AgentName {
63
+ return (agentNames as readonly unknown[]).includes(value);
64
+ }
65
+
66
+ function shownValue(value: unknown): string {
67
+ return typeof value === "string"
68
+ ? JSON.stringify(value.slice(0, maxShownValueLength))
69
+ : "none";
70
+ }
71
+
72
+ function agentFrom(value: unknown, source: string): AgentChoice {
73
+ if (isAgentName(value)) return { ok: true, agent: value };
74
+ return {
75
+ ok: false,
76
+ failure: `${source}: unknown agent ${shownValue(value)} (expected one of: ${agentNames.join(", ")})`,
77
+ };
78
+ }
79
+
80
+ function errorMessage(error: unknown): string {
81
+ return error instanceof Error ? error.message : String(error);
82
+ }
83
+
84
+ function readSettingsText(): string | null {
85
+ try {
86
+ return readFileSync(settingsPath, "utf-8");
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ function settingsAgent(): AgentChoice {
94
+ let text: string | null;
95
+ try {
96
+ text = readSettingsText();
97
+ } catch (error) {
98
+ return {
99
+ ok: false,
100
+ failure: `could not read ${settingsPath}: ${errorMessage(error)}`,
101
+ };
102
+ }
103
+ if (text === null) return { ok: true, agent: defaultAgent };
104
+ let settings: unknown;
105
+ try {
106
+ settings = JSON.parse(text);
107
+ } catch {
108
+ return { ok: false, failure: `${settingsPath} is not valid JSON` };
109
+ }
110
+ const agent =
111
+ typeof settings === "object" &&
112
+ settings !== null &&
113
+ Object.hasOwn(settings, "agent")
114
+ ? (settings as { agent: unknown }).agent
115
+ : undefined;
116
+ return agentFrom(agent, settingsPath);
117
+ }
118
+
119
+ // --agent overrides the settings file; a missing settings file means copilot.
120
+ // Never throws: a bad choice comes back as a failure for the pre-flight.
121
+ export function readAgent(agentOption: string | null): AgentChoice {
122
+ return agentOption === null
123
+ ? settingsAgent()
124
+ : agentFrom(agentOption, agentFlag);
125
+ }
126
+
127
+ export function agentArguments(args: string[]): AgentArguments {
128
+ const index = args.indexOf(agentFlag);
129
+ if (index === -1) return { agentOption: null, rest: args };
130
+ const value = args[index + 1];
131
+ if (value === undefined || value.startsWith("-")) {
132
+ throw new Error(`${agentFlag} needs a value: ${agentNames.join(" or ")}`);
133
+ }
134
+ const rest = [...args.slice(0, index), ...args.slice(index + 2)];
135
+ if (rest.includes(agentFlag))
136
+ throw new Error(`${agentFlag} given more than once`);
137
+ return { agentOption: value, rest };
138
+ }
139
+
140
+ export function agentToolCheck(choice: AgentChoice): [string, boolean] {
141
+ if (!choice.ok) return [choice.failure, false];
142
+ return [`${choice.agent} not installed`, succeeds("which", [choice.agent])];
143
+ }
144
+
145
+ export function transcriptExtension(agent: AgentName): string {
146
+ return transcriptExtensions[agent];
147
+ }
148
+
149
+ function copilotArgs(session: AgentSession): string[] {
150
+ return [
151
+ "-p",
152
+ session.prompt,
153
+ "--model",
154
+ copilotModels[session.kind],
155
+ "--effort",
156
+ copilotEffort,
157
+ "--yolo",
158
+ "--no-ask-user",
159
+ "--experimental",
160
+ "--name",
161
+ session.name,
162
+ "--share",
163
+ session.transcriptPath,
164
+ ];
165
+ }
166
+
167
+ function claudeArgs(session: AgentSession): string[] {
168
+ return [
169
+ "-p",
170
+ session.prompt,
171
+ "--model",
172
+ claudeModels[session.kind],
173
+ "--dangerously-skip-permissions",
174
+ "--output-format",
175
+ "stream-json",
176
+ "--verbose",
177
+ ];
178
+ }
179
+
180
+ // No model: Vibe uses the user's active_model (overridable via VIBE_ACTIVE_MODEL).
181
+ // --trust only trusts the session's working directory and is not persisted.
182
+ function vibeArgs(session: AgentSession): string[] {
183
+ return [
184
+ "-p",
185
+ session.prompt,
186
+ "--trust",
187
+ "--auto-approve",
188
+ "--disabled-tools",
189
+ "ask_user_question",
190
+ "--output",
191
+ "streaming",
192
+ ];
193
+ }
194
+
195
+ export function agentCli(
196
+ agent: AgentName,
197
+ session: AgentSession,
198
+ ): AgentCommand {
199
+ if (agent === "claude") {
200
+ return {
201
+ binary: "claude",
202
+ args: claudeArgs(session),
203
+ transcriptFromStdout: true,
204
+ };
205
+ }
206
+ if (agent === "vibe") {
207
+ return {
208
+ binary: "vibe",
209
+ args: vibeArgs(session),
210
+ transcriptFromStdout: true,
211
+ };
212
+ }
213
+ return {
214
+ binary: "copilot",
215
+ args: copilotArgs(session),
216
+ transcriptFromStdout: false,
217
+ };
218
+ }
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // Shared by the overnight scripts of yaawn-implement-plan and
4
+ // yaawn-verify-executed-plan.
5
+
6
+ export function keepMacAwakeWhileRunning(): Promise<void> {
7
+ return new Promise((resolve, reject) => {
8
+ const caffeinate = spawn(
9
+ "caffeinate",
10
+ ["-ims", "-w", String(process.pid)],
11
+ { stdio: "ignore" },
12
+ );
13
+ // caffeinate -w waits for this process to exit; a ref'd handle would make
14
+ // this process wait for caffeinate in turn, so the run would never exit.
15
+ caffeinate.unref();
16
+ caffeinate.once("spawn", resolve).once("error", reject);
17
+ });
18
+ }
19
+
20
+ function shouldUseColor(): boolean {
21
+ const { NO_COLOR, FORCE_COLOR, TERM } = process.env;
22
+ if (NO_COLOR) return false;
23
+ if (FORCE_COLOR && FORCE_COLOR !== "0") return true;
24
+ return Boolean(process.stdout.isTTY) && TERM !== "dumb";
25
+ }
26
+
27
+ export function printWalkAwayBanner(agent?: string): void {
28
+ const useColor = shouldUseColor();
29
+ const ansi = (code: string): string => (useColor ? `\x1b[${code}m` : "");
30
+ const red = ansi("31");
31
+ const yellow = ansi("33");
32
+ const green = ansi("32");
33
+ const dim = ansi("2");
34
+ const reset = ansi("0");
35
+ // Every banner line is 59 chars: " │" + 56 interior chars + "│". The
36
+ // header's 39-char text field names the running agent; runs without
37
+ // one (verification) keep the plain overnight label.
38
+ const header =
39
+ agent === undefined
40
+ ? "overnight — staying awake"
41
+ : `${agent} is working ...`;
42
+ const headerPadding = " ".repeat(39 - header.length);
43
+ console.log(String.raw`
44
+ ╭────────────────────────────────────────────────────────╮
45
+ │ ${red}●${reset} ${yellow}●${reset} ${green}●${reset} ${dim}${header}${reset}${headerPadding}│
46
+ ├────────────────────────────────────────────────────────┤
47
+ │ You can walk away now. │
48
+ │ The Mac stays awake until the run ends. │
49
+ │ │
50
+ │ Plug it in - you can close the lid. │
51
+ │ Leave this terminal and the editor open. │
52
+ │ │
53
+ │ ${green}~ $${reset} █ │
54
+ ╰────────────────────────────────────────────────────────╯
55
+ `);
56
+ }
@@ -0,0 +1,233 @@
1
+ import path from "node:path";
2
+ import { type Issue, sectionContent } from "./targets.js";
3
+
4
+ // Lane and conflict planning: which lanes conflict, which can run together,
5
+ // and which Yaawn: implementation-group label each ready lane issue carries. Pure —
6
+ // no gh, file system, or process access — so it is unit tested directly.
7
+
8
+ export interface Lane {
9
+ issue: number;
10
+ targets: Issue[];
11
+ }
12
+
13
+ export interface FilesToChange {
14
+ files: string[];
15
+ invalid: string[];
16
+ }
17
+
18
+ export interface ImplementationGroup {
19
+ number: number;
20
+ issues: number[];
21
+ }
22
+
23
+ export interface LabelAction {
24
+ issue: number;
25
+ label: string;
26
+ kind: "add" | "remove";
27
+ }
28
+
29
+ export const filesToChangeHeading = "Files to Change";
30
+ const bulletPattern = /^[-*+]\s+(.*)$/;
31
+ const groupLabelPrefix = "Yaawn: implementation-group-";
32
+ const groupLabelPattern = /^Yaawn: implementation-group-([1-9]\d*)$/;
33
+
34
+ // --- lanes
35
+
36
+ export function laneIssueNumbers(lane: Lane): number[] {
37
+ return [...new Set([lane.issue, ...lane.targets.map((target) => target.number)])];
38
+ }
39
+
40
+ function normalizedRepoPath(entry: string): string | null {
41
+ const raw = entry.replaceAll("`", "").trim();
42
+ if (!raw || path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) return null;
43
+ if (raw.split(/[\\/]/).includes("..")) return null;
44
+ const normalized = path.posix.normalize(raw).replace(/\/+$/, "");
45
+ return normalized && normalized !== "." ? normalized : null;
46
+ }
47
+
48
+ export function filesToChange(issue: Issue): FilesToChange {
49
+ const result: FilesToChange = { files: [], invalid: [] };
50
+ sectionContent(issue.body, filesToChangeHeading)
51
+ .split("\n")
52
+ .map((line) => line.trim())
53
+ .filter(Boolean)
54
+ .forEach((line) => {
55
+ const entry = bulletPattern.exec(line)?.[1];
56
+ const file = entry === undefined ? null : normalizedRepoPath(entry);
57
+ if (file) result.files.push(file);
58
+ else result.invalid.push(line);
59
+ });
60
+ return result;
61
+ }
62
+
63
+ export function laneFiles(lane: Lane): Set<string> {
64
+ return new Set(lane.targets.flatMap((target) => filesToChange(target).files));
65
+ }
66
+
67
+ export function lanePairs(lanes: Lane[]): [Lane, Lane][] {
68
+ return lanes.flatMap((first, index) =>
69
+ lanes.slice(index + 1).map((second): [Lane, Lane] => [first, second]),
70
+ );
71
+ }
72
+
73
+ // Every issue a lane covers — its own number and its targets — mapped to the
74
+ // lanes that cover it.
75
+ export function laneOwners(lanes: Lane[]): Map<number, number[]> {
76
+ const owners = new Map<number, number[]>();
77
+ lanes.forEach((lane) =>
78
+ laneIssueNumbers(lane).forEach((issue) =>
79
+ owners.set(issue, [...(owners.get(issue) ?? []), lane.issue]),
80
+ ),
81
+ );
82
+ return owners;
83
+ }
84
+
85
+ // --- conflicts
86
+
87
+ // Adjacency map: lane issue → set of lane issues it conflicts with.
88
+ export function conflictGraph(lanes: Lane[]): Map<number, Set<number>> {
89
+ const graph = new Map<number, Set<number>>();
90
+ lanes.forEach((lane) => graph.set(lane.issue, new Set()));
91
+ const addEdge = (a: number, b: number): void => {
92
+ if (a === b) return;
93
+ graph.get(a)?.add(b);
94
+ graph.get(b)?.add(a);
95
+ };
96
+
97
+ const owners = laneOwners(lanes);
98
+ const files = new Map(lanes.map((lane) => [lane.issue, laneFiles(lane)]));
99
+
100
+ lanePairs(lanes).forEach(([first, second]) => {
101
+ const shared = [...(files.get(first.issue) ?? [])].filter((file) =>
102
+ files.get(second.issue)?.has(file),
103
+ );
104
+ if (shared.length > 0) addEdge(first.issue, second.issue);
105
+ });
106
+
107
+ [...owners].forEach(([, laneIssues]) => {
108
+ for (let i = 0; i < laneIssues.length; i++) {
109
+ for (let j = i + 1; j < laneIssues.length; j++) {
110
+ addEdge(laneIssues[i], laneIssues[j]);
111
+ }
112
+ }
113
+ });
114
+
115
+ lanes.forEach((lane) => {
116
+ lane.targets.forEach((target) => {
117
+ target.externalDependsOn.forEach((dependency) => {
118
+ (owners.get(dependency) ?? [])
119
+ .filter((owner) => owner !== lane.issue)
120
+ .forEach((owner) => addEdge(lane.issue, owner));
121
+ });
122
+ });
123
+ });
124
+
125
+ return graph;
126
+ }
127
+
128
+ // Every maximal independent set in the conflict graph — each is a group of
129
+ // lanes that can run together (no shared files, targets, or cross-lane deps).
130
+ // Recursive: pick a vertex v; every MIS either contains v (recurse on the
131
+ // rest minus v's neighbours) or excludes v (recurse on the rest, keep only
132
+ // sets that touch a neighbour of v so they stay maximal in the full graph).
133
+ export function allCompatibleGroups(
134
+ vertices: number[],
135
+ graph: Map<number, Set<number>>,
136
+ ): number[][] {
137
+ if (vertices.length === 0) return [[]];
138
+ const [v, ...rest] = vertices;
139
+ const neighbors = graph.get(v) ?? new Set<number>();
140
+ const withoutNeighbors = rest.filter((u) => !neighbors.has(u));
141
+ const withV = allCompatibleGroups(withoutNeighbors, graph).map((s) => [v, ...s]);
142
+ const withoutV = allCompatibleGroups(rest, graph).filter((s) =>
143
+ s.some((u) => neighbors.has(u)),
144
+ );
145
+ return [...withV, ...withoutV];
146
+ }
147
+
148
+ // --- group identification
149
+
150
+ // Larger sets first; equal sizes by their members in ascending order, so the
151
+ // set with the smallest member issue number wins.
152
+ function compareSets(first: number[], second: number[]): number {
153
+ if (first.length !== second.length) return second.length - first.length;
154
+ const differing = first.findIndex((issue, index) => issue !== second[index]);
155
+ return differing === -1 ? 0 : first[differing] - second[differing];
156
+ }
157
+
158
+ function bestCompatibleSet(issues: number[], graph: Map<number, Set<number>>): number[] {
159
+ return allCompatibleGroups(issues, graph)
160
+ .map((set) => [...set].sort((a, b) => a - b))
161
+ .sort(compareSets)[0];
162
+ }
163
+
164
+ // Partitions the lane issues: the best-ranked maximal set among the still
165
+ // unassigned issues becomes the next group, until every issue has one. Picking
166
+ // per issue instead would split overlapping sets into needless singletons.
167
+ function partitionIntoSets(lanes: Lane[]): number[][] {
168
+ const graph = conflictGraph(lanes);
169
+ const sets: number[][] = [];
170
+ let unassigned = lanes.map((lane) => lane.issue).sort((a, b) => a - b);
171
+ while (unassigned.length > 0) {
172
+ const set = bestCompatibleSet(unassigned, graph);
173
+ sets.push(set);
174
+ unassigned = unassigned.filter((issue) => !set.includes(issue));
175
+ }
176
+ return sets;
177
+ }
178
+
179
+ // Groups are numbered 1..k by set size descending, ties by smallest member, so
180
+ // the numbering is deterministic while membership is unchanged.
181
+ export function assignGroups(lanes: Lane[]): Map<number, number> {
182
+ const sets = partitionIntoSets(lanes).sort(compareSets);
183
+ return new Map(
184
+ sets.flatMap((set, index) => set.map((issue): [number, number] => [issue, index + 1])),
185
+ );
186
+ }
187
+
188
+ export function groupsOf(assignment: Map<number, number>): ImplementationGroup[] {
189
+ const members = new Map<number, number[]>();
190
+ [...assignment]
191
+ .sort(([a], [b]) => a - b)
192
+ .forEach(([issue, group]) => members.set(group, [...(members.get(group) ?? []), issue]));
193
+ return [...members]
194
+ .sort(([a], [b]) => a - b)
195
+ .map(([number, issues]) => ({ number, issues }));
196
+ }
197
+
198
+ // --- labels
199
+
200
+ export const groupLabel = (group: number): string => `${groupLabelPrefix}${group}`;
201
+
202
+ export const isGroupLabel = (label: string): boolean => label.startsWith(groupLabelPrefix);
203
+
204
+ function groupLabelsOf(labels: string[]): string[] {
205
+ return labels.filter(isGroupLabel);
206
+ }
207
+
208
+ // Every wrong group label is removed, the correct one added when absent.
209
+ export function labelActions(
210
+ assignment: Map<number, number>,
211
+ issueLabels: Map<number, string[]>,
212
+ ): LabelAction[] {
213
+ return [...assignment].flatMap(([issue, group]) => {
214
+ const wanted = groupLabel(group);
215
+ const current = groupLabelsOf(issueLabels.get(issue) ?? []);
216
+ const removals = current
217
+ .filter((label) => label !== wanted)
218
+ .map((label): LabelAction => ({ issue, label, kind: "remove" }));
219
+ const additions: LabelAction[] = current.includes(wanted)
220
+ ? []
221
+ : [{ issue, label: wanted, kind: "add" }];
222
+ return [...removals, ...additions];
223
+ });
224
+ }
225
+
226
+ // --- label reset
227
+
228
+ // Only well-formed group labels count: "Yaawn: implementation-group-01" is not one.
229
+ export function groupLabelRemovals(issue: number, labels: string[]): LabelAction[] {
230
+ return labels
231
+ .filter((label) => groupLabelPattern.test(label))
232
+ .map((label): LabelAction => ({ issue, label, kind: "remove" }));
233
+ }