@grantx/fleet-cli 0.1.4 → 0.1.6

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.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@grantx/fleet-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "files": ["src/", "bin/"],
6
6
  "bin": {
7
7
  "fleet": "./bin/fleet"
8
8
  },
9
9
  "dependencies": {
10
- "@grantx/fleet-core": "0.1.3"
10
+ "@grantx/fleet-core": "0.1.5"
11
11
  },
12
12
  "engines": {
13
13
  "node": ">=22.0.0"
@@ -0,0 +1,354 @@
1
+ // agent-templates.js — Rich CLAUDE.md generation for fleet agents.
2
+ // Produces 80-120 line agent files matching the Clawcob quality bar.
3
+ // Each agent gets: identity, core principle, FROZEN decisions, domain,
4
+ // NOT-domain, gotchas, quality standards, tools, and constraints.
5
+
6
+ /**
7
+ * Generate a rich CLAUDE.md for a worker agent.
8
+ * @param {object} agent - Full agent definition from the wizard
9
+ * @param {object} config - Fleet config (for roster, team type, repos)
10
+ * @returns {string} Markdown content
11
+ */
12
+ export function generateRichAgentClaudeMd(agent, config) {
13
+ const lines = [];
14
+ const titleFromRole = agent.role.split('—')[0]?.trim() || agent.role.split('-')[0]?.trim() || agent.role;
15
+
16
+ // Header + identity
17
+ lines.push(`# ${agent.name} — ${titleFromRole}`);
18
+ lines.push('');
19
+ if (agent.identity) {
20
+ lines.push(agent.identity);
21
+ } else {
22
+ lines.push(`You are ${agent.name}, a fleet agent specializing in: ${agent.role}.`);
23
+ }
24
+ lines.push('');
25
+
26
+ // Core principle
27
+ if (agent.principles?.length > 0) {
28
+ lines.push('## Core Principle');
29
+ lines.push(agent.principles[0]);
30
+ lines.push('');
31
+ }
32
+
33
+ // FROZEN decisions
34
+ if (agent.frozenDecisions?.length > 0) {
35
+ lines.push('## FROZEN Decisions (DO NOT CHANGE)');
36
+ agent.frozenDecisions.forEach((d, i) => {
37
+ lines.push(`${i + 1}. ${d}`);
38
+ });
39
+ lines.push('');
40
+ }
41
+
42
+ // Domain
43
+ if (agent.domains?.length > 0) {
44
+ lines.push('## Domain');
45
+ agent.domains.forEach(d => lines.push(`- ${d}`));
46
+ lines.push('');
47
+ }
48
+
49
+ // NOT domain
50
+ if (agent.offLimits?.length > 0) {
51
+ lines.push('## NOT Your Domain');
52
+ agent.offLimits.forEach(d => {
53
+ // Try to identify which agent owns this area
54
+ const owner = findDomainOwner(d, config.agents, agent.name);
55
+ lines.push(owner ? `- ${d} (${owner}'s domain)` : `- ${d}`);
56
+ });
57
+ lines.push('');
58
+ }
59
+
60
+ // Known gotchas
61
+ if (agent.gotchas?.length > 0) {
62
+ lines.push('## Known Gotchas');
63
+ agent.gotchas.forEach(g => lines.push(`- ${g}`));
64
+ lines.push('');
65
+ }
66
+
67
+ // Quality standards (user-provided + team-type defaults)
68
+ const standards = [
69
+ ...(agent.qualityStandards || []),
70
+ ...teamQualityStandards(config.teamType, agent),
71
+ ];
72
+ if (standards.length > 0) {
73
+ lines.push('## Quality Standard');
74
+ // Deduplicate
75
+ const seen = new Set();
76
+ standards.forEach(s => {
77
+ const key = s.toLowerCase();
78
+ if (!seen.has(key)) {
79
+ seen.add(key);
80
+ lines.push(`- ${s}`);
81
+ }
82
+ });
83
+ lines.push('');
84
+ }
85
+
86
+ // Owned file patterns
87
+ if (agent.filePatterns?.length > 0) {
88
+ lines.push('## Owned File Patterns');
89
+ agent.filePatterns.forEach(p => lines.push(`- \`${p}\``));
90
+ lines.push('');
91
+ }
92
+
93
+ // Specialty keywords
94
+ if (agent.keywords?.length > 0) {
95
+ lines.push('## Specialty Keywords');
96
+ lines.push(agent.keywords.join(', '));
97
+ lines.push('');
98
+ }
99
+
100
+ // Additional principles (beyond the first/core one)
101
+ if (agent.principles?.length > 1) {
102
+ lines.push('## Working Principles');
103
+ agent.principles.slice(1).forEach(p => lines.push(`- ${p}`));
104
+ lines.push('');
105
+ }
106
+
107
+ // Tools section
108
+ lines.push('## Tools');
109
+ lines.push('');
110
+ lines.push('### Knowledge Store (grantx-kb)');
111
+ lines.push(` export FLEET_AGENT_ID=${agent.name}`);
112
+ lines.push('');
113
+ lines.push(` grantx-kb task list --to ${agent.name} --status pending`);
114
+ lines.push(' grantx-kb task claim <uuid>');
115
+ lines.push(' grantx-kb task start <uuid>');
116
+ lines.push(' grantx-kb task complete <uuid> --result "..."');
117
+ lines.push('');
118
+ lines.push(' grantx-kb knowledge search --query "<keywords>"');
119
+ lines.push(' grantx-kb knowledge add --category lesson --title "..." --content "..."');
120
+ lines.push('');
121
+
122
+ // Code repository section
123
+ if (config.repos?.length > 0) {
124
+ lines.push('### Code Repositories');
125
+ config.repos.forEach(r => {
126
+ if (r.path) {
127
+ lines.push(` ${r.slug} → ${r.path}`);
128
+ } else {
129
+ lines.push(` ${r.slug}`);
130
+ }
131
+ });
132
+ lines.push('');
133
+ }
134
+ lines.push('### Git Workflow');
135
+ lines.push(` Work in branches: feat/${agent.name}/<description>`);
136
+ lines.push(' Create PRs via: gh pr create --title "..." --body "..."');
137
+ lines.push('');
138
+
139
+ // Constraints
140
+ lines.push('### What You Cannot Do');
141
+ const constraints = generateConstraints(agent, config.agents);
142
+ constraints.forEach(c => lines.push(`- ${c}`));
143
+ lines.push('');
144
+
145
+ return lines.join('\n');
146
+ }
147
+
148
+ /**
149
+ * Generate a rich CLAUDE.md for the conductor agent.
150
+ * @param {object} conductorInput - Conductor soul inputs from wizard
151
+ * @param {Array} roster - All agents (including conductor)
152
+ * @param {object} config - Fleet config
153
+ * @returns {string} Markdown content
154
+ */
155
+ export function generateConductorClaudeMd(conductorInput, roster, config) {
156
+ const lines = [];
157
+ const workers = roster.filter(a => !a.isConductor);
158
+
159
+ // Header + identity
160
+ lines.push('# Conductor — Fleet Orchestrator');
161
+ lines.push('');
162
+ lines.push('You are a dispatcher, not a doer. You never write code. You never make product decisions. You read the state of the world, decide what needs to happen next, and tell the right agent to do it. Your value is in sequencing, unblocking, and synthesis — seeing connections across domains that individual agents miss.');
163
+ lines.push('');
164
+
165
+ // Core principle from philosophy
166
+ if (conductorInput.philosophy) {
167
+ lines.push('## Core Principle');
168
+ lines.push(`You are the only entity with visibility across all ${workers.length} agent workspaces. The fleet values: ${conductorInput.philosophy}.`);
169
+ lines.push('');
170
+ }
171
+
172
+ // What you do
173
+ lines.push('## What You Do');
174
+ lines.push('1. Check the task queue for stalled tasks. Reassign if blocked > 2 hours.');
175
+ lines.push('2. Check all agent heartbeats. Flag agents with no heartbeat or error status.');
176
+ lines.push(`3. Read all ${workers.length} agent workspaces. Synthesize cross-domain insights.`);
177
+ lines.push('4. Create tasks that connect discoveries across agents.');
178
+ lines.push('5. Generate sprint status reports.');
179
+ lines.push('6. Flag blockers to human when you cannot resolve them.');
180
+ lines.push('');
181
+
182
+ // What you never do
183
+ lines.push('## What You NEVER Do');
184
+ lines.push('- Write application code (that\'s what the workers are for)');
185
+ lines.push('- Deploy to production');
186
+ lines.push('- Approve PRs');
187
+ lines.push('- Make product decisions');
188
+ lines.push('- Modify your own CLAUDE.md');
189
+ lines.push('');
190
+
191
+ // Fleet roster table
192
+ lines.push('## Fleet Roster');
193
+ lines.push('');
194
+ lines.push('| Agent | Domain |');
195
+ lines.push('|-------|--------|');
196
+ lines.push('| Conductor (you) | Orchestration |');
197
+ workers.forEach(a => {
198
+ const domain = a.domains?.join(', ') || a.role;
199
+ lines.push(`| ${a.name} | ${domain} |`);
200
+ });
201
+ lines.push('');
202
+
203
+ // Decision framework
204
+ lines.push('## Decision Framework');
205
+ lines.push('1. Is this ticket blocked? Identify the blocker, create unblock task for the right agent.');
206
+
207
+ // Build routing rules from roster
208
+ const routingRules = workers.map(a => {
209
+ const domains = a.domains?.slice(0, 2).join('/') || a.role.split(',')[0].trim();
210
+ return `${domains} = ${a.name}`;
211
+ });
212
+ lines.push(`2. Is this the right agent? ${routingRules.join('. ')}.`);
213
+ lines.push('3. Is this urgent? Priority 1-3 = assign immediately. 4-7 = queue. 8-10 = background.');
214
+ lines.push('4. Cross-agent dependencies? Create tasks for both agents with clear sequencing.');
215
+ lines.push('');
216
+
217
+ // Escalation rules
218
+ if (conductorInput.escalation) {
219
+ lines.push('## Escalation Rules');
220
+ lines.push(conductorInput.escalation);
221
+ lines.push('');
222
+ }
223
+
224
+ // Sprint management
225
+ if (conductorInput.sprintStyle) {
226
+ lines.push('## Sprint Management');
227
+ lines.push(conductorInput.sprintStyle);
228
+ lines.push('');
229
+ }
230
+
231
+ // Custom rules
232
+ if (conductorInput.customRules?.length > 0) {
233
+ lines.push('## Additional Rules');
234
+ conductorInput.customRules.forEach(r => lines.push(`- ${r}`));
235
+ lines.push('');
236
+ }
237
+
238
+ // FROZEN decisions (union of all agents')
239
+ const allFrozen = [];
240
+ workers.forEach(a => {
241
+ if (a.frozenDecisions?.length > 0) {
242
+ allFrozen.push(...a.frozenDecisions);
243
+ }
244
+ });
245
+ if (allFrozen.length > 0) {
246
+ lines.push('## FROZEN Decisions (Fleet-wide)');
247
+ allFrozen.forEach((d, i) => lines.push(`${i + 1}. ${d}`));
248
+ lines.push('');
249
+ }
250
+
251
+ // Tools
252
+ lines.push('## Tools');
253
+ lines.push('');
254
+ lines.push('### grantx-kb (Knowledge Store CLI)');
255
+ lines.push(' export FLEET_AGENT_ID=conductor');
256
+ lines.push('');
257
+ lines.push('Check task queue:');
258
+ lines.push(' grantx-kb task list --status pending');
259
+ lines.push(' grantx-kb task list --status in_progress');
260
+ lines.push(' grantx-kb task list --status blocked');
261
+ lines.push('');
262
+ lines.push('Assign work:');
263
+ lines.push(' grantx-kb task create --to <agent> --title "..." --description "..." --priority <1-10>');
264
+ lines.push('');
265
+ lines.push('Check agent health:');
266
+ lines.push(' grantx-kb heartbeat check --since 30');
267
+ lines.push('');
268
+ lines.push('Read knowledge:');
269
+ lines.push(' grantx-kb knowledge search --query "<keywords>"');
270
+ lines.push('');
271
+
272
+ // Constraints
273
+ lines.push('### Constraints');
274
+ lines.push('- You can only reach: Supabase (knowledge store), GitHub (read-only), and fleet agents.');
275
+ lines.push('- Every action you take is logged to the audit trail.');
276
+ lines.push('- You cannot access email, CRM, messaging, or the web.');
277
+ lines.push('');
278
+
279
+ return lines.join('\n');
280
+ }
281
+
282
+ // ── Helpers ───────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Find which agent in the roster owns a given domain area.
286
+ */
287
+ function findDomainOwner(area, agents, excludeName) {
288
+ const areaLower = area.toLowerCase();
289
+ for (const agent of agents || []) {
290
+ if (agent.name === excludeName || agent.isConductor) continue;
291
+ const domains = (agent.domains || []).map(d => d.toLowerCase());
292
+ const role = (agent.role || '').toLowerCase();
293
+ if (domains.some(d => areaLower.includes(d) || d.includes(areaLower))) {
294
+ return agent.name;
295
+ }
296
+ if (role.includes(areaLower)) {
297
+ return agent.name;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Generate team-type-specific quality standards.
305
+ */
306
+ function teamQualityStandards(teamType, agent) {
307
+ const standards = {
308
+ ml: [
309
+ 'Never ship a model without evaluation on holdout set',
310
+ 'Always compare to production baseline before promoting',
311
+ 'Every experiment logged with full hyperparameters',
312
+ ],
313
+ data: [
314
+ 'All pipelines must be idempotent',
315
+ 'Schema changes require migration scripts',
316
+ 'Monitor before shipping — dashboards first, code second',
317
+ ],
318
+ fullstack: [
319
+ 'Test coverage for new code',
320
+ 'API changes require contract documentation',
321
+ 'Responsive design — test at mobile and desktop breakpoints',
322
+ ],
323
+ skunkworks: [
324
+ 'Every experiment has a hypothesis and success criteria',
325
+ 'Results captured in knowledge store immediately',
326
+ 'Reproducibility: document environment, data version, and steps',
327
+ ],
328
+ };
329
+ return standards[teamType] || [];
330
+ }
331
+
332
+ /**
333
+ * Generate agent-specific constraints based on roster composition.
334
+ */
335
+ function generateConstraints(agent, allAgents) {
336
+ const constraints = [
337
+ 'Do not modify files outside your owned patterns unless necessary',
338
+ 'Do not send messages to humans directly (Slack is conductor\'s domain)',
339
+ ];
340
+
341
+ // Check for specialized agents and add their exclusive privileges
342
+ for (const other of allAgents || []) {
343
+ if (other.name === agent.name || other.isConductor) continue;
344
+ const roleLower = (other.role || '').toLowerCase();
345
+ if (roleLower.includes('deploy') || roleLower.includes('mlops')) {
346
+ constraints.push(`Do not deploy to production (${other.name}'s exclusive privilege)`);
347
+ }
348
+ if (roleLower.includes('review') || roleLower.includes('qa') || roleLower.includes('audit')) {
349
+ constraints.push(`Do not approve PRs (${other.name}'s exclusive privilege)`);
350
+ }
351
+ }
352
+
353
+ return constraints;
354
+ }
@@ -0,0 +1,228 @@
1
+ // agent-wizard.js — Interactive agent builder for fleet setup.
2
+ // Walks users through defining each agent's identity, domain, principles,
3
+ // FROZEN decisions, gotchas, and quality standards. Produces agents at
4
+ // the Clawcob quality bar.
5
+
6
+ import { ask, askRequired, askYesNo, askCommaSeparated, askMultiline, askList, printHeader } from './prompt-utils.js';
7
+ import { scanCodebase, generateRoster } from './generate.js';
8
+
9
+ /**
10
+ * Run the full agent builder wizard.
11
+ * @param {readline.Interface} rl
12
+ * @param {string} projectRoot
13
+ * @param {string} teamType - 'ml' | 'data' | 'fullstack' | 'skunkworks'
14
+ * @param {Array} repos - Linked GitHub repos with scan results
15
+ * @returns {Promise<{workers: Array, conductorInput: object}>}
16
+ */
17
+ export async function runAgentWizard(rl, projectRoot, teamType, repos) {
18
+ printHeader('Agent Builder');
19
+
20
+ // Run codebase scan as suggestion engine
21
+ const scanResult = scanCodebase(projectRoot);
22
+ const suggestions = generateRoster(scanResult);
23
+ const suggestedWorkers = suggestions.filter(a => !a.isConductor);
24
+
25
+ let workers;
26
+
27
+ if (suggestedWorkers.length > 0) {
28
+ console.log(` Codebase suggests: ${suggestedWorkers.map(a => a.name).join(', ')}`);
29
+ const useSuggestions = await askYesNo(rl, 'Use suggestions as starting point?', true);
30
+
31
+ if (useSuggestions) {
32
+ workers = await buildFromSuggestions(rl, suggestedWorkers, teamType);
33
+ } else {
34
+ workers = await buildFromScratch(rl, teamType);
35
+ }
36
+ } else {
37
+ console.log(' No specific specialties detected in codebase.');
38
+ workers = await buildFromScratch(rl, teamType);
39
+ }
40
+
41
+ // Conductor soul
42
+ const conductorInput = await promptConductorSoul(rl, workers, teamType);
43
+
44
+ return { workers, conductorInput };
45
+ }
46
+
47
+ /**
48
+ * Build agents from scan suggestions — pre-fills name/role/keywords/patterns,
49
+ * then asks the DEEP questions for each.
50
+ */
51
+ async function buildFromSuggestions(rl, suggestions, teamType) {
52
+ console.log(`\n Refining ${suggestions.length} suggested agent(s)...`);
53
+ console.log(' You can skip an agent by entering "skip" for the name.\n');
54
+
55
+ const workers = [];
56
+ for (let i = 0; i < suggestions.length; i++) {
57
+ const s = suggestions[i];
58
+ console.log(` --- Agent ${i + 1} of ${suggestions.length} (suggested: ${s.name}) ---\n`);
59
+
60
+ const name = await ask(rl, 'Name', s.name);
61
+ if (name === 'skip') {
62
+ console.log(' Skipped.\n');
63
+ continue;
64
+ }
65
+
66
+ const agent = await promptAgentDetails(rl, name, s.role, s.keywords, s.filePatterns, teamType);
67
+ workers.push(agent);
68
+ console.log(` ✓ ${agent.name} defined\n`);
69
+ }
70
+
71
+ // Offer to add more
72
+ const addMore = await askYesNo(rl, 'Add more agents beyond suggestions?', false);
73
+ if (addMore) {
74
+ const extras = await buildFromScratch(rl, teamType);
75
+ workers.push(...extras);
76
+ }
77
+
78
+ return workers;
79
+ }
80
+
81
+ /**
82
+ * Build agents from scratch — user defines everything.
83
+ */
84
+ async function buildFromScratch(rl, teamType) {
85
+ const countStr = await ask(rl, 'How many worker agents? (1-10)', '3');
86
+ const count = Math.max(1, Math.min(10, parseInt(countStr, 10) || 3));
87
+ console.log('');
88
+
89
+ const workers = [];
90
+ for (let i = 0; i < count; i++) {
91
+ console.log(` --- Agent ${i + 1} of ${count} ---\n`);
92
+
93
+ const name = await askRequired(rl, 'Name (short, lowercase)');
94
+ const agent = await promptAgentDetails(rl, name, '', [], [], teamType);
95
+ workers.push(agent);
96
+ console.log(` ✓ ${agent.name} defined\n`);
97
+ }
98
+
99
+ return workers;
100
+ }
101
+
102
+ /**
103
+ * Prompt for all the deep details of a single agent.
104
+ */
105
+ async function promptAgentDetails(rl, name, defaultRole, defaultKeywords, defaultPatterns, teamType) {
106
+ const role = await askRequired(rl, `Role (one-line)${defaultRole ? ` [${defaultRole}]` : ''}`);
107
+ const finalRole = role || defaultRole;
108
+
109
+ const identity = await ask(rl, 'Identity (first person, 1-2 sentences — who is this agent?)');
110
+
111
+ const domains = await askCommaSeparated(rl, 'Domain (comma-separated areas this agent owns)');
112
+
113
+ const patternsDefault = defaultPatterns?.length > 0 ? defaultPatterns.join(', ') : '';
114
+ const patternsStr = await ask(rl, `File patterns (globs)${patternsDefault ? ` [${patternsDefault}]` : ''}`);
115
+ const filePatterns = patternsStr
116
+ ? patternsStr.split(',').map(s => s.trim()).filter(Boolean)
117
+ : defaultPatterns || [];
118
+
119
+ const offLimits = await askCommaSeparated(rl, 'NOT domain (what this agent must never touch)');
120
+
121
+ const principles = await askCommaSeparated(rl, 'Core principles (comma-separated)');
122
+
123
+ const frozenDecisions = await askMultiline(rl, 'FROZEN decisions (one per line)');
124
+
125
+ const gotchas = await askMultiline(rl, 'Known gotchas (one per line)');
126
+
127
+ const qualityStandards = await askMultiline(rl, 'Quality standards (one per line)');
128
+
129
+ // Derive keywords from domain + role + defaults
130
+ const keywords = deriveKeywords(finalRole, domains, defaultKeywords);
131
+
132
+ return {
133
+ name: name.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
134
+ role: finalRole,
135
+ identity: identity || null,
136
+ domains,
137
+ keywords,
138
+ filePatterns,
139
+ offLimits,
140
+ principles,
141
+ frozenDecisions,
142
+ gotchas,
143
+ qualityStandards,
144
+ isConductor: false,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Prompt for the conductor soul.
150
+ */
151
+ async function promptConductorSoul(rl, workers, teamType) {
152
+ printHeader('Conductor Soul');
153
+ console.log(' The conductor orchestrates your fleet — it dispatches, sequences, and synthesizes.\n');
154
+
155
+ const philosophy = await askRequired(rl, 'Fleet philosophy (what does your team value?)');
156
+
157
+ const escalation = await ask(rl,
158
+ 'Escalation rules — when should conductor ask a human vs auto-dispatch?'
159
+ );
160
+
161
+ const sprintStyle = await ask(rl, 'Sprint style (e.g. weekly sprints, daily check-ins)');
162
+
163
+ const customRules = await askMultiline(rl, 'Additional conductor rules (one per line)');
164
+
165
+ return {
166
+ philosophy,
167
+ escalation: escalation || 'Escalate product decisions and security concerns. Auto-dispatch routine development tasks.',
168
+ sprintStyle: sprintStyle || 'Weekly sprints. Flag at-risk at 50% elapsed / <30% done.',
169
+ customRules,
170
+ };
171
+ }
172
+
173
+ // ── Helpers ───────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Derive dispatch keywords from role text and domains.
177
+ * Expands known terms to synonyms for better SmartDispatcher matching.
178
+ */
179
+ function deriveKeywords(role, domains, existingKeywords) {
180
+ const keywords = new Set(existingKeywords || []);
181
+
182
+ // Tokenize role
183
+ const roleTokens = role.toLowerCase().replace(/[^a-z0-9\s-]/g, '').split(/\s+/);
184
+ roleTokens.forEach(t => {
185
+ if (t.length > 2 && !STOP_WORDS.has(t)) keywords.add(t);
186
+ });
187
+
188
+ // Add domains directly
189
+ domains.forEach(d => {
190
+ d.toLowerCase().split(/\s+/).forEach(t => {
191
+ if (t.length > 2 && !STOP_WORDS.has(t)) keywords.add(t);
192
+ });
193
+ });
194
+
195
+ // Expand known terms
196
+ for (const kw of [...keywords]) {
197
+ const expansions = KEYWORD_EXPANSIONS[kw];
198
+ if (expansions) {
199
+ expansions.forEach(e => keywords.add(e));
200
+ }
201
+ }
202
+
203
+ return [...keywords];
204
+ }
205
+
206
+ const STOP_WORDS = new Set([
207
+ 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'are', 'was',
208
+ 'has', 'have', 'been', 'will', 'can', 'all', 'not', 'but', 'also',
209
+ ]);
210
+
211
+ const KEYWORD_EXPANSIONS = {
212
+ ranking: ['rank', 'ndcg', 'lgbm', 'lightgbm', 'model'],
213
+ 'machine': ['ml', 'model', 'training'],
214
+ training: ['train', 'model', 'eval'],
215
+ frontend: ['ui', 'component', 'page', 'css', 'layout'],
216
+ backend: ['api', 'endpoint', 'route', 'server'],
217
+ api: ['endpoint', 'route', 'handler', 'middleware'],
218
+ database: ['db', 'query', 'migration', 'schema', 'sql'],
219
+ pipeline: ['etl', 'data', 'pipeline', 'ingestion'],
220
+ deploy: ['deployment', 'release', 'rollout', 'ci', 'cd'],
221
+ testing: ['test', 'qa', 'coverage', 'lint', 'review'],
222
+ docker: ['container', 'dockerfile', 'compose'],
223
+ kubernetes: ['k8s', 'helm', 'pod', 'deployment'],
224
+ prompt: ['llm', 'claude', 'gpt', 'eval', 'nlp'],
225
+ embedding: ['vector', 'retrieval', 'similarity', 'search'],
226
+ feature: ['features', 'parity', 'drift', 'lineage'],
227
+ experiment: ['hypothesis', 'research', 'ablation', 'eval'],
228
+ };