@proletariat/cli 0.3.30 → 0.3.32

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 (39) hide show
  1. package/dist/commands/diet.d.ts +20 -0
  2. package/dist/commands/diet.js +181 -0
  3. package/dist/commands/mcp-server.js +2 -1
  4. package/dist/commands/priority/add.d.ts +15 -0
  5. package/dist/commands/priority/add.js +70 -0
  6. package/dist/commands/priority/list.d.ts +10 -0
  7. package/dist/commands/priority/list.js +34 -0
  8. package/dist/commands/priority/remove.d.ts +13 -0
  9. package/dist/commands/priority/remove.js +54 -0
  10. package/dist/commands/priority/set.d.ts +14 -0
  11. package/dist/commands/priority/set.js +60 -0
  12. package/dist/commands/pull.d.ts +23 -0
  13. package/dist/commands/pull.js +219 -0
  14. package/dist/commands/roadmap/generate.js +10 -5
  15. package/dist/commands/template/apply.js +5 -4
  16. package/dist/commands/template/create.js +9 -5
  17. package/dist/commands/ticket/create.js +6 -5
  18. package/dist/commands/ticket/edit.js +9 -9
  19. package/dist/commands/ticket/list.d.ts +2 -0
  20. package/dist/commands/ticket/list.js +20 -13
  21. package/dist/commands/ticket/update.js +8 -5
  22. package/dist/commands/work/spawn.d.ts +13 -0
  23. package/dist/commands/work/spawn.js +388 -1
  24. package/dist/lib/mcp/tools/diet.d.ts +6 -0
  25. package/dist/lib/mcp/tools/diet.js +261 -0
  26. package/dist/lib/mcp/tools/index.d.ts +1 -0
  27. package/dist/lib/mcp/tools/index.js +1 -0
  28. package/dist/lib/mcp/tools/template.js +1 -1
  29. package/dist/lib/mcp/tools/ticket.js +48 -3
  30. package/dist/lib/pmo/diet.d.ts +102 -0
  31. package/dist/lib/pmo/diet.js +127 -0
  32. package/dist/lib/pmo/storage/base.d.ts +5 -0
  33. package/dist/lib/pmo/storage/base.js +47 -0
  34. package/dist/lib/pmo/types.d.ts +12 -6
  35. package/dist/lib/pmo/types.js +6 -2
  36. package/dist/lib/pmo/utils.d.ts +40 -0
  37. package/dist/lib/pmo/utils.js +76 -0
  38. package/oclif.manifest.json +2872 -2534
  39. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { getWorkspaceInfo, getTicketTmuxSession, killTmuxSession } from '../../l
7
7
  import { isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled } from '../../lib/execution/runners.js';
8
8
  import { shouldOutputJson, outputSuccessAsJson, outputErrorAsJson, createMetadata, outputConfirmationNeededAsJson, outputExecutionResultAsJson, } from '../../lib/prompt-json.js';
9
9
  import { FlagResolver } from '../../lib/flags/index.js';
10
+ import { loadDietConfig, formatDietConfig, } from '../../lib/pmo/diet.js';
10
11
  export default class WorkSpawn extends PMOCommand {
11
12
  static description = 'Spawn work for multiple tickets by column (batch mode)';
12
13
  static strict = false; // Allow multiple ticket ID args without defining them
@@ -19,6 +20,11 @@ export default class WorkSpawn extends PMOCommand {
19
20
  '<%= config.bin %> <%= command.id %> --dry-run # Preview without executing',
20
21
  '<%= config.bin %> <%= command.id %> --many --json # Output ticket choices as JSON (for agents)',
21
22
  '<%= config.bin %> <%= command.id %> TKT-001 --action custom --message "Add unit tests" # Custom prompt',
23
+ '<%= config.bin %> <%= command.id %> --count 5 --action implement # Top 5 by rank',
24
+ '<%= config.bin %> <%= command.id %> --count 10 --diet --action groom # Diet-balanced',
25
+ '<%= config.bin %> <%= command.id %> --count 5 --category ship --action implement # Filtered by category',
26
+ '<%= config.bin %> <%= command.id %> --count 5 --priority P0 --action implement # Filtered by priority',
27
+ '<%= config.bin %> <%= command.id %> --count 10 --diet --category ship,grow --action groom # Combined',
22
28
  ];
23
29
  static flags = {
24
30
  ...pmoBaseFlags,
@@ -119,6 +125,27 @@ export default class WorkSpawn extends PMOCommand {
119
125
  description: 'Use independent git clone instead of worktree (more isolation, no real-time sync)',
120
126
  default: false,
121
127
  }),
128
+ count: Flags.integer({
129
+ char: 'n',
130
+ description: 'Number of tickets to spawn (selects top N by rank)',
131
+ min: 1,
132
+ }),
133
+ diet: Flags.boolean({
134
+ description: 'Apply diet-balanced category weighting when selecting tickets',
135
+ default: false,
136
+ }),
137
+ category: Flags.string({
138
+ description: 'Filter tickets by category (comma-separated, e.g., ship,grow)',
139
+ }),
140
+ priority: Flags.string({
141
+ description: 'Filter tickets by priority (comma-separated, e.g., P0,P1)',
142
+ }),
143
+ epic: Flags.string({
144
+ description: 'Filter tickets by epic ID',
145
+ }),
146
+ status: Flags.string({
147
+ description: 'Filter tickets by status name (e.g., Backlog, Ready)',
148
+ }),
122
149
  };
123
150
  async execute() {
124
151
  const { flags, argv } = await this.parse(WorkSpawn);
@@ -200,12 +227,16 @@ export default class WorkSpawn extends PMOCommand {
200
227
  }
201
228
  // Note: With ephemeral agents, we no longer need to check for available pre-registered agents
202
229
  // Agents are created on-demand when spawning
203
- // Determine spawn mode: All, Many, or Args (positional ticket IDs)
230
+ // Determine spawn mode: All, Many, Args, or Count
204
231
  let spawnMode = 'all';
205
232
  if (ticketIdArgs.length > 0) {
206
233
  // Ticket IDs provided as positional args
207
234
  spawnMode = 'args';
208
235
  }
236
+ else if (flags.count) {
237
+ // Count-based selection with optional filters
238
+ spawnMode = 'count';
239
+ }
209
240
  else if (flags.all) {
210
241
  spawnMode = 'all';
211
242
  }
@@ -226,6 +257,7 @@ export default class WorkSpawn extends PMOCommand {
226
257
  type: 'list',
227
258
  message: 'How would you like to spawn work?',
228
259
  choices: () => [
260
+ { name: jsonMode ? 'Count - Spawn next N tickets (with optional filters)' : '🔢 Count - Spawn next N tickets (with optional filters)', value: 'count' },
229
261
  { name: jsonMode ? 'All - Spawn all tickets in a column' : '📦 All - Spawn all tickets tickets in a column', value: 'all' },
230
262
  { name: jsonMode ? 'Many - Select specific tickets to spawn' : '✅ Many - Select specific tickets to spawn', value: 'many' },
231
263
  ],
@@ -331,6 +363,289 @@ export default class WorkSpawn extends PMOCommand {
331
363
  this.log(styles.header(`🚀 Spawn: ${ticketsToSpawn.length} ticket(s)`));
332
364
  this.log(styles.muted(`Tickets: ${ticketsToSpawn.map(t => t.id).join(', ')}`));
333
365
  }
366
+ else if (spawnMode === 'count') {
367
+ // COUNT MODE: Select top N tickets by rank with optional filters and diet balancing
368
+ let targetCount = flags.count;
369
+ // If count not provided via flag (interactive mode selected 'count'), prompt for it
370
+ if (!targetCount) {
371
+ const countResolver = new FlagResolver({
372
+ commandName: 'work spawn',
373
+ baseCommand: 'prlt work spawn',
374
+ jsonMode,
375
+ flags: {},
376
+ context: { projectId },
377
+ });
378
+ countResolver.addPrompt({
379
+ flagName: 'spawnCount',
380
+ type: 'input',
381
+ message: 'How many agents to spawn?',
382
+ default: '5',
383
+ validate: (value) => {
384
+ const num = Number.parseInt(String(value), 10);
385
+ if (Number.isNaN(num) || num < 1)
386
+ return 'Please enter a number >= 1';
387
+ return true;
388
+ },
389
+ transform: (value) => Number.parseInt(String(value), 10),
390
+ });
391
+ const countResult = await countResolver.resolve();
392
+ targetCount = countResult.spawnCount;
393
+ }
394
+ // Determine selection strategy
395
+ let useDiet = flags.diet;
396
+ // In interactive mode, prompt for selection strategy if not specified via flags
397
+ const hasFilterFlags = flags.category || flags.priority || flags.epic || flags.status;
398
+ if (!useDiet && !hasFilterFlags && !flags.count) {
399
+ // Interactive mode - let user choose strategy
400
+ const strategyResolver = new FlagResolver({
401
+ commandName: 'work spawn',
402
+ baseCommand: 'prlt work spawn',
403
+ jsonMode,
404
+ flags: {},
405
+ });
406
+ strategyResolver.addPrompt({
407
+ flagName: 'strategy',
408
+ type: 'list',
409
+ message: 'Selection strategy:',
410
+ choices: () => [
411
+ { name: jsonMode ? 'Top ranked - Select by position order' : '📊 Top ranked - Select by position order', value: 'top' },
412
+ { name: jsonMode ? 'Diet-balanced - Balance across category weights' : '⚖️ Diet-balanced - Balance across category weights', value: 'diet' },
413
+ { name: jsonMode ? 'Filtered - Pick filters to narrow selection' : '🔍 Filtered - Pick filters to narrow selection', value: 'filtered' },
414
+ ],
415
+ });
416
+ const strategyResult = await strategyResolver.resolve();
417
+ if (strategyResult.strategy === 'diet') {
418
+ useDiet = true;
419
+ }
420
+ else if (strategyResult.strategy === 'filtered') {
421
+ // Prompt for filters interactively
422
+ const filterResolver = new FlagResolver({
423
+ commandName: 'work spawn',
424
+ baseCommand: 'prlt work spawn',
425
+ jsonMode,
426
+ flags: {},
427
+ });
428
+ // Get unique categories from tickets for choices
429
+ const categories = [...new Set(allTickets.map(t => t.category).filter(Boolean))];
430
+ if (categories.length > 0) {
431
+ filterResolver.addPrompt({
432
+ flagName: 'filterCategory',
433
+ type: 'checkbox',
434
+ message: 'Filter by category (space to toggle, enter to continue):',
435
+ choices: () => categories.map(c => ({ name: c, value: c })),
436
+ });
437
+ }
438
+ // Get unique priorities
439
+ const priorities = [...new Set(allTickets.map(t => t.priority).filter(Boolean))];
440
+ if (priorities.length > 0) {
441
+ const priorityOrder = ['P0', 'P1', 'P2', 'P3'];
442
+ priorities.sort((a, b) => {
443
+ const ai = priorityOrder.indexOf(a);
444
+ const bi = priorityOrder.indexOf(b);
445
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
446
+ });
447
+ filterResolver.addPrompt({
448
+ flagName: 'filterPriority',
449
+ type: 'checkbox',
450
+ message: 'Filter by priority (space to toggle, enter to continue):',
451
+ choices: () => priorities.map(p => ({ name: p, value: p })),
452
+ });
453
+ }
454
+ // Get epics for choices
455
+ const epics = await this.storage.listEpics(projectId);
456
+ if (epics.length > 0) {
457
+ filterResolver.addPrompt({
458
+ flagName: 'filterEpic',
459
+ type: 'list',
460
+ message: 'Filter by epic:',
461
+ choices: () => [
462
+ { name: jsonMode ? 'Any epic' : '(any)', value: '' },
463
+ ...epics.map(e => ({ name: `${e.id} - ${e.title}`, value: e.id })),
464
+ ],
465
+ });
466
+ }
467
+ // Status filter
468
+ filterResolver.addPrompt({
469
+ flagName: 'filterStatus',
470
+ type: 'list',
471
+ message: 'Filter by status:',
472
+ choices: () => [
473
+ { name: jsonMode ? 'Any status' : '(any)', value: '' },
474
+ ...columnNames.map(name => {
475
+ const count = allTickets.filter(t => t.statusName === name).length;
476
+ return { name: `${name} (${count})`, value: name };
477
+ }),
478
+ ],
479
+ });
480
+ const filterResult = await filterResolver.resolve();
481
+ // Apply interactive filter selections to flags for use below
482
+ if (filterResult.filterCategory && filterResult.filterCategory.length > 0) {
483
+ flags.category = filterResult.filterCategory.join(',');
484
+ }
485
+ if (filterResult.filterPriority && filterResult.filterPriority.length > 0) {
486
+ flags.priority = filterResult.filterPriority.join(',');
487
+ }
488
+ if (filterResult.filterEpic) {
489
+ flags.epic = filterResult.filterEpic;
490
+ }
491
+ if (filterResult.filterStatus) {
492
+ flags.status = filterResult.filterStatus;
493
+ }
494
+ }
495
+ }
496
+ // Build candidate pool: start with all tickets, apply filters
497
+ let candidates = [...allTickets];
498
+ // Apply category filter
499
+ if (flags.category) {
500
+ const categoryList = flags.category.split(',').map(c => c.trim().toLowerCase());
501
+ candidates = candidates.filter(t => {
502
+ const ticketCat = (t.category || '').toLowerCase();
503
+ return categoryList.includes(ticketCat);
504
+ });
505
+ }
506
+ // Apply priority filter
507
+ if (flags.priority) {
508
+ const priorityList = flags.priority.split(',').map(p => p.trim().toUpperCase());
509
+ candidates = candidates.filter(t => {
510
+ const ticketPriority = (t.priority || '').toUpperCase();
511
+ return priorityList.includes(ticketPriority);
512
+ });
513
+ }
514
+ // Apply epic filter
515
+ if (flags.epic) {
516
+ candidates = candidates.filter(t => t.epicId === flags.epic);
517
+ }
518
+ // Apply status filter
519
+ if (flags.status) {
520
+ const statusName = flags.status.toLowerCase();
521
+ candidates = candidates.filter(t => (t.statusName || '').toLowerCase() === statusName);
522
+ }
523
+ // Sort by position (rank order)
524
+ candidates.sort((a, b) => (a.position || 0) - (b.position || 0));
525
+ // Filter out blocked tickets
526
+ const unblockedCandidates = [];
527
+ let skippedBlocked = 0;
528
+ for (const ticket of candidates) {
529
+ // eslint-disable-next-line no-await-in-loop -- Sequential dependency check
530
+ const blocked = await this.storage.isTicketBlocked(ticket.id);
531
+ if (blocked) {
532
+ skippedBlocked++;
533
+ }
534
+ else {
535
+ unblockedCandidates.push(ticket);
536
+ }
537
+ }
538
+ if (unblockedCandidates.length === 0) {
539
+ db.close();
540
+ const msg = `No eligible tickets found${skippedBlocked > 0 ? ` (${skippedBlocked} blocked)` : ''}.`;
541
+ return handleError('NO_ELIGIBLE_TICKETS', msg);
542
+ }
543
+ // Select tickets using chosen strategy
544
+ if (useDiet) {
545
+ // Diet-balanced selection
546
+ const dietConfig = loadDietConfig(db);
547
+ ticketsToSpawn = this.selectDietBalanced(unblockedCandidates, targetCount, dietConfig);
548
+ }
549
+ else {
550
+ // Top ranked (position order) - just take the first N
551
+ ticketsToSpawn = unblockedCandidates.slice(0, targetCount);
552
+ }
553
+ if (ticketsToSpawn.length === 0) {
554
+ db.close();
555
+ return handleError('NO_TICKETS_SELECTED', 'No tickets could be selected with the given criteria.');
556
+ }
557
+ // Show preview
558
+ this.log('');
559
+ this.log(styles.header(`🚀 Spawn: ${ticketsToSpawn.length} ticket(s)${useDiet ? ' (diet-balanced)' : ''}`));
560
+ if (useDiet) {
561
+ const dietConfig = loadDietConfig(db);
562
+ // Show category breakdown
563
+ const catCounts = new Map();
564
+ for (const t of ticketsToSpawn) {
565
+ const cat = t.category || 'uncategorized';
566
+ catCounts.set(cat, (catCounts.get(cat) || 0) + 1);
567
+ }
568
+ const breakdown = [...catCounts.entries()].map(([cat, count]) => `${count}x ${cat}`).join(', ');
569
+ this.log(styles.muted(` Diet: ${formatDietConfig(dietConfig)}`));
570
+ this.log(styles.muted(` Breakdown: ${breakdown}`));
571
+ }
572
+ if (skippedBlocked > 0) {
573
+ this.log(styles.muted(` Skipped: ${skippedBlocked} blocked ticket(s)`));
574
+ }
575
+ this.log(styles.muted(` Tickets: ${ticketsToSpawn.map(t => t.id).join(', ')}`));
576
+ // Confirmation (unless --yes)
577
+ if (!flags.yes && !jsonMode) {
578
+ const confirmResolver = new FlagResolver({
579
+ commandName: 'work spawn',
580
+ baseCommand: 'prlt work spawn',
581
+ jsonMode,
582
+ flags: {},
583
+ });
584
+ confirmResolver.addPrompt({
585
+ flagName: 'confirmed',
586
+ type: 'list',
587
+ message: `Spawn ${ticketsToSpawn.length} ticket(s)?`,
588
+ choices: () => [
589
+ { name: 'Yes', value: 'yes' },
590
+ { name: 'No', value: 'no' },
591
+ ],
592
+ });
593
+ const confirmResult = await confirmResolver.resolve();
594
+ if (confirmResult.confirmed === 'no') {
595
+ db.close();
596
+ this.log(styles.muted('Cancelled.'));
597
+ return;
598
+ }
599
+ }
600
+ // In JSON mode without --yes, return confirmation_needed
601
+ if (jsonMode && !flags.yes) {
602
+ const metadata = createMetadata('work spawn', flags);
603
+ const ticketIds = ticketsToSpawn.map(t => t.id).join(' ');
604
+ let confirmCmd = `prlt work spawn ${ticketIds}`;
605
+ if (flags.action)
606
+ confirmCmd += ` --action ${flags.action}`;
607
+ if (flags.display)
608
+ confirmCmd += ` --display ${flags.display}`;
609
+ if (flags['run-on-host'])
610
+ confirmCmd += ' --run-on-host';
611
+ if (flags['skip-permissions'])
612
+ confirmCmd += ' --skip-permissions';
613
+ if (flags.executor)
614
+ confirmCmd += ` --executor ${flags.executor}`;
615
+ if (flags.session)
616
+ confirmCmd += ` --session ${flags.session}`;
617
+ if (flags['create-pr'])
618
+ confirmCmd += ' --create-pr';
619
+ if (flags['no-pr'])
620
+ confirmCmd += ' --no-pr';
621
+ if (flags.clone)
622
+ confirmCmd += ' --clone';
623
+ if (flags.focus)
624
+ confirmCmd += ' --focus';
625
+ confirmCmd += ' --yes';
626
+ const plan = {
627
+ tickets: ticketsToSpawn.map(t => ({
628
+ id: t.id,
629
+ title: t.title,
630
+ status: t.statusName,
631
+ category: t.category,
632
+ priority: t.priority,
633
+ })),
634
+ action: flags.action,
635
+ count: ticketsToSpawn.length,
636
+ diet: useDiet,
637
+ filters: {
638
+ category: flags.category || null,
639
+ priority: flags.priority || null,
640
+ epic: flags.epic || null,
641
+ status: flags.status || null,
642
+ },
643
+ };
644
+ db.close();
645
+ outputConfirmationNeededAsJson(plan, confirmCmd, `Ready to spawn ${ticketsToSpawn.length} agent(s). Run with --yes to execute.`, metadata);
646
+ return;
647
+ }
648
+ }
334
649
  else if (spawnMode === 'all') {
335
650
  // ALL MODE: Column picker, then spawn all tickets in that column
336
651
  let targetColumn = flags.column;
@@ -1094,4 +1409,76 @@ export default class WorkSpawn extends PMOCommand {
1094
1409
  throw error;
1095
1410
  }
1096
1411
  }
1412
+ /**
1413
+ * Select tickets using diet-balanced category weighting.
1414
+ * Uses a two-pass algorithm:
1415
+ * Pass 1: Walk candidates in position order, pull if category not over ceiling.
1416
+ * Pass 2: Force-pull from underrepresented categories.
1417
+ */
1418
+ selectDietBalanced(candidates, targetCount, dietConfig) {
1419
+ const selected = [];
1420
+ const selectedIds = new Set();
1421
+ // Build category count map (start from zero since we're selecting fresh)
1422
+ const categoryCounts = new Map();
1423
+ for (const ratio of dietConfig.ratios) {
1424
+ categoryCounts.set(ratio.category, 0);
1425
+ }
1426
+ // Calculate ceiling per category
1427
+ const getCeiling = (category) => {
1428
+ const ratio = dietConfig.ratios.find(r => r.category === category);
1429
+ if (!ratio)
1430
+ return targetCount; // No ceiling for uncategorized
1431
+ return Math.ceil(targetCount * ratio.target);
1432
+ };
1433
+ // Pass 1: Walk candidates in order, pull if under ceiling
1434
+ const skipped = [];
1435
+ for (const ticket of candidates) {
1436
+ if (selected.length >= targetCount)
1437
+ break;
1438
+ const cat = (ticket.category || '').toLowerCase();
1439
+ const currentCount = categoryCounts.get(cat) || 0;
1440
+ const ceiling = getCeiling(cat);
1441
+ if (currentCount < ceiling) {
1442
+ selected.push(ticket);
1443
+ selectedIds.add(ticket.id);
1444
+ categoryCounts.set(cat, currentCount + 1);
1445
+ }
1446
+ else {
1447
+ skipped.push(ticket);
1448
+ }
1449
+ }
1450
+ // Pass 2: Force-pull from underrepresented categories
1451
+ if (selected.length < targetCount) {
1452
+ for (const ratio of dietConfig.ratios) {
1453
+ if (selected.length >= targetCount)
1454
+ break;
1455
+ const currentCount = categoryCounts.get(ratio.category) || 0;
1456
+ const targetForCat = Math.ceil(targetCount * ratio.target);
1457
+ if (currentCount < targetForCat) {
1458
+ const catTickets = skipped.filter(t => (t.category || '').toLowerCase() === ratio.category && !selectedIds.has(t.id));
1459
+ for (const ticket of catTickets) {
1460
+ if (selected.length >= targetCount)
1461
+ break;
1462
+ if ((categoryCounts.get(ratio.category) || 0) >= targetForCat)
1463
+ break;
1464
+ selected.push(ticket);
1465
+ selectedIds.add(ticket.id);
1466
+ categoryCounts.set(ratio.category, (categoryCounts.get(ratio.category) || 0) + 1);
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+ // If still under target, fill from any remaining candidates
1472
+ if (selected.length < targetCount) {
1473
+ for (const ticket of candidates) {
1474
+ if (selected.length >= targetCount)
1475
+ break;
1476
+ if (!selectedIds.has(ticket.id)) {
1477
+ selected.push(ticket);
1478
+ selectedIds.add(ticket.id);
1479
+ }
1480
+ }
1481
+ }
1482
+ return selected;
1483
+ }
1097
1484
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * MCP Diet & Pull Tools
3
+ */
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import type { McpToolContext } from '../types.js';
6
+ export declare function registerDietTools(server: McpServer, ctx: McpToolContext): void;