@proletariat/cli 0.3.36 → 0.3.40

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 (64) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/mcp-server.js +2 -1
  9. package/dist/commands/pmo/init.js +12 -40
  10. package/dist/commands/qa/index.d.ts +54 -0
  11. package/dist/commands/qa/index.js +762 -0
  12. package/dist/commands/repo/view.js +2 -8
  13. package/dist/commands/session/attach.js +4 -4
  14. package/dist/commands/session/health.js +4 -4
  15. package/dist/commands/session/list.js +1 -19
  16. package/dist/commands/session/peek.js +6 -6
  17. package/dist/commands/session/poke.js +2 -2
  18. package/dist/commands/ticket/epic.js +17 -43
  19. package/dist/commands/work/spawn-all.js +1 -1
  20. package/dist/commands/work/spawn.js +15 -4
  21. package/dist/commands/work/start.js +17 -9
  22. package/dist/commands/work/watch.js +1 -1
  23. package/dist/commands/workspace/prune.js +3 -3
  24. package/dist/hooks/init.js +10 -2
  25. package/dist/lib/agents/commands.d.ts +5 -0
  26. package/dist/lib/agents/commands.js +143 -97
  27. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  28. package/dist/lib/database/drizzle-schema.js +53 -0
  29. package/dist/lib/database/index.d.ts +47 -1
  30. package/dist/lib/database/index.js +138 -20
  31. package/dist/lib/execution/runners.d.ts +34 -0
  32. package/dist/lib/execution/runners.js +134 -7
  33. package/dist/lib/execution/session-utils.d.ts +5 -0
  34. package/dist/lib/execution/session-utils.js +45 -3
  35. package/dist/lib/execution/spawner.js +15 -2
  36. package/dist/lib/execution/storage.d.ts +1 -1
  37. package/dist/lib/execution/storage.js +17 -2
  38. package/dist/lib/execution/types.d.ts +1 -0
  39. package/dist/lib/mcp/tools/index.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.js +1 -0
  41. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  42. package/dist/lib/mcp/tools/tmux.js +182 -0
  43. package/dist/lib/mcp/tools/work.js +52 -0
  44. package/dist/lib/pmo/schema.d.ts +1 -1
  45. package/dist/lib/pmo/schema.js +1 -0
  46. package/dist/lib/pmo/storage/base.js +207 -0
  47. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  48. package/dist/lib/pmo/storage/dependencies.js +11 -3
  49. package/dist/lib/pmo/storage/epics.js +1 -1
  50. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  51. package/dist/lib/pmo/storage/helpers.js +36 -26
  52. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  53. package/dist/lib/pmo/storage/projects.js +207 -119
  54. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  55. package/dist/lib/pmo/storage/specs.js +274 -188
  56. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  57. package/dist/lib/pmo/storage/tickets.js +350 -290
  58. package/dist/lib/pmo/storage/views.d.ts +2 -0
  59. package/dist/lib/pmo/storage/views.js +183 -130
  60. package/dist/lib/prompt-json.d.ts +5 -0
  61. package/dist/lib/prompt-json.js +9 -0
  62. package/oclif.manifest.json +3922 -3819
  63. package/package.json +11 -6
  64. package/LICENSE +0 -190
@@ -5,7 +5,7 @@ import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import { execSync } from 'node:child_process';
7
7
  import inquirer from 'inquirer';
8
- import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAgentWorktrees, addAgentsToDatabase, removeAgentsFromDatabase, addEphemeralAgentToDatabase, getEphemeralAgentNames, getActiveTheme, markAgentCleaned, discoverAgentsOnDisk } from '../database/index.js';
8
+ import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAgentWorktrees, addAgentsToDatabase, removeAgentsFromDatabase, tryAddEphemeralAgentToDatabase, getEphemeralAgentNames, getActiveTheme, markAgentCleaned, discoverAgentsOnDisk } from '../database/index.js';
9
9
  import { isValidAgentName, getSuggestedAgentNames, generateEphemeralAgentName, getThemePersistentDir, getThemeEphemeralDir, extractBaseName, getAgentBaseName, } from '../themes.js';
10
10
  import { createDevcontainerConfig } from '../execution/devcontainer.js';
11
11
  import { getGitIdentity } from '../pr/index.js';
@@ -395,16 +395,21 @@ export async function removeAgentsFromWorkspace(workspaceInfo, agentNames) {
395
395
  }
396
396
  return { removed, failed };
397
397
  }
398
+ /**
399
+ * Maximum number of retries when a name collision is detected at the DB level.
400
+ * Each retry generates a fresh name to avoid repeated collisions.
401
+ */
402
+ const EPHEMERAL_CREATE_MAX_RETRIES = 5;
398
403
  /**
399
404
  * Create an ephemeral agent on-demand for a spawn operation.
400
405
  * Creates worktree in agents/temp/{name}/
406
+ *
407
+ * Concurrency-safe: if the generated name collides at the DB level
408
+ * (e.g. a parallel process inserted the same name first), we clean up
409
+ * the on-disk artifacts, regenerate a name, and retry up to
410
+ * EPHEMERAL_CREATE_MAX_RETRIES times.
401
411
  */
402
412
  export async function createEphemeralAgent(workspaceInfo, options) {
403
- // Get existing agent names for uniqueness check
404
- const existingNames = new Set([
405
- ...Array.from(getEphemeralAgentNames(workspaceInfo.path)),
406
- ...workspaceInfo.agents.map(a => a.name.toLowerCase())
407
- ]);
408
413
  const log = options?.log;
409
414
  // Get theme: use provided themeId, or fall back to workspace's active theme
410
415
  let themeId = options?.themeId;
@@ -417,117 +422,158 @@ export async function createEphemeralAgent(workspaceInfo, options) {
417
422
  // Extract base names currently in use by active agents
418
423
  // This helps the generator prefer fresh base names
419
424
  const inUseBaseNames = new Set(workspaceInfo.agents.map(agent => getAgentBaseName(agent).toLowerCase()));
420
- // Create a conflict checker for external resources (tmux sessions, directories)
421
- const checkExternalConflict = (candidateName) => {
422
- // Check if a tmux session with this name already exists (could be from manual creation)
423
- if (tmuxSessionExists(candidateName)) {
424
- return { conflict: true, reason: `tmux session "${candidateName}" already exists` };
425
- }
426
- // Check if the directory already exists in agents/temp/
427
- const candidateDir = path.join(tempAgentsBasePath, candidateName);
428
- if (fs.existsSync(candidateDir)) {
429
- return { conflict: true, reason: `directory "${candidateDir}" already exists` };
430
- }
431
- return { conflict: false };
432
- };
433
- // Log when conflicts are skipped during name generation
434
- const onConflictSkipped = (name, reason) => {
435
- log?.(`⚠️ Skipping name "${name}": ${reason}`);
436
- };
437
- // Generate unique ephemeral name using workspace theme
438
- const nameOptions = {
439
- themeId,
440
- checkExternalConflict,
441
- onConflictSkipped,
442
- inUseBaseNames
443
- };
444
- const agentName = generateEphemeralAgentName(existingNames, nameOptions);
445
- // Extract base name from the generated name (e.g., "bezos" from "bold-bezos" or "bold-bezos-2")
446
- const baseName = extractBaseName(agentName);
447
- // Create temp agents directory if it doesn't exist
448
- if (!fs.existsSync(tempAgentsBasePath)) {
449
- fs.mkdirSync(tempAgentsBasePath, { recursive: true });
450
- }
451
- const agentDir = path.join(tempAgentsBasePath, agentName);
452
- // Create agent directory
453
- if (!fs.existsSync(agentDir)) {
454
- fs.mkdirSync(agentDir, { recursive: true });
455
- }
456
- // Create worktrees/clones for each repository
457
425
  const reposPath = path.join(workspaceInfo.path, 'repos');
458
426
  const mountMode = options?.mountMode || 'worktree';
459
- if (fs.existsSync(reposPath) && workspaceInfo.repositories.length > 0) {
460
- for (const repo of workspaceInfo.repositories) {
461
- const sourceRepoPath = path.join(reposPath, repo.name);
462
- const targetPath = path.join(agentDir, repo.name);
463
- if (fs.existsSync(sourceRepoPath) && !fs.existsSync(targetPath)) {
464
- if (mountMode === 'clone') {
465
- // CLONE MODE: Create independent git clone
466
- try {
467
- // Get remote URL from source repo
468
- const remoteUrl = execSync('git remote get-url origin', {
469
- cwd: sourceRepoPath,
470
- encoding: 'utf-8',
471
- stdio: ['pipe', 'pipe', 'pipe']
472
- }).trim();
473
- if (remoteUrl) {
474
- execSync(`git clone "${remoteUrl}" "${targetPath}"`, {
427
+ for (let attempt = 0; attempt <= EPHEMERAL_CREATE_MAX_RETRIES; attempt++) {
428
+ // Re-read existing names on every attempt so we see names that were
429
+ // inserted by concurrent processes since our last try
430
+ const existingNames = new Set([
431
+ ...Array.from(getEphemeralAgentNames(workspaceInfo.path)),
432
+ ...workspaceInfo.agents.map(a => a.name.toLowerCase())
433
+ ]);
434
+ // Create a conflict checker for external resources (tmux sessions, directories)
435
+ const checkExternalConflict = (candidateName) => {
436
+ if (tmuxSessionExists(candidateName)) {
437
+ return { conflict: true, reason: `tmux session "${candidateName}" already exists` };
438
+ }
439
+ const candidateDir = path.join(tempAgentsBasePath, candidateName);
440
+ if (fs.existsSync(candidateDir)) {
441
+ return { conflict: true, reason: `directory "${candidateDir}" already exists` };
442
+ }
443
+ return { conflict: false };
444
+ };
445
+ const onConflictSkipped = (name, reason) => {
446
+ log?.(`⚠️ Skipping name "${name}": ${reason}`);
447
+ };
448
+ const nameOptions = {
449
+ themeId,
450
+ checkExternalConflict,
451
+ onConflictSkipped,
452
+ inUseBaseNames
453
+ };
454
+ const agentName = generateEphemeralAgentName(existingNames, nameOptions);
455
+ const baseName = extractBaseName(agentName);
456
+ // Create temp agents directory if it doesn't exist
457
+ if (!fs.existsSync(tempAgentsBasePath)) {
458
+ fs.mkdirSync(tempAgentsBasePath, { recursive: true });
459
+ }
460
+ const agentDir = path.join(tempAgentsBasePath, agentName);
461
+ // Create agent directory
462
+ if (!fs.existsSync(agentDir)) {
463
+ fs.mkdirSync(agentDir, { recursive: true });
464
+ }
465
+ // Create worktrees/clones for each repository
466
+ if (fs.existsSync(reposPath) && workspaceInfo.repositories.length > 0) {
467
+ for (const repo of workspaceInfo.repositories) {
468
+ const sourceRepoPath = path.join(reposPath, repo.name);
469
+ const targetPath = path.join(agentDir, repo.name);
470
+ if (fs.existsSync(sourceRepoPath) && !fs.existsSync(targetPath)) {
471
+ if (mountMode === 'clone') {
472
+ // CLONE MODE: Create independent git clone
473
+ try {
474
+ const remoteUrl = execSync('git remote get-url origin', {
475
+ cwd: sourceRepoPath,
476
+ encoding: 'utf-8',
477
+ stdio: ['pipe', 'pipe', 'pipe']
478
+ }).trim();
479
+ if (remoteUrl) {
480
+ execSync(`git clone "${remoteUrl}" "${targetPath}"`, {
481
+ stdio: 'pipe'
482
+ });
483
+ }
484
+ }
485
+ catch {
486
+ if (!fs.existsSync(targetPath)) {
487
+ fs.mkdirSync(targetPath, { recursive: true });
488
+ }
489
+ }
490
+ }
491
+ else {
492
+ // WORKTREE MODE: Create git worktree
493
+ try {
494
+ execSync(`git worktree add --detach "${targetPath}"`, {
495
+ cwd: sourceRepoPath,
475
496
  stdio: 'pipe'
476
497
  });
477
498
  }
478
- }
479
- catch {
480
- // If clone fails, try to just create the directory
481
- if (!fs.existsSync(targetPath)) {
482
- fs.mkdirSync(targetPath, { recursive: true });
499
+ catch {
500
+ if (!fs.existsSync(targetPath)) {
501
+ fs.mkdirSync(targetPath, { recursive: true });
502
+ }
483
503
  }
484
504
  }
485
505
  }
486
- else {
487
- // WORKTREE MODE: Create git worktree
506
+ }
507
+ }
508
+ // Create devcontainer config if not skipped (uses shared devcontainer generator)
509
+ if (!options?.skipDevcontainer) {
510
+ const devcontainerDir = path.join(agentDir, '.devcontainer');
511
+ if (!fs.existsSync(devcontainerDir)) {
512
+ const gitIdentity = getGitIdentity();
513
+ createDevcontainerConfig({
514
+ agentName,
515
+ agentDir,
516
+ repoWorktrees: mountMode === 'worktree' ? workspaceInfo.repositories.map(r => r.name) : undefined,
517
+ mountMode,
518
+ gitUserName: gitIdentity.name || undefined,
519
+ gitUserEmail: gitIdentity.email || undefined,
520
+ });
521
+ }
522
+ }
523
+ // Attempt atomic DB insertion — returns null on name collision
524
+ const agent = tryAddEphemeralAgentToDatabase(workspaceInfo.path, agentName, baseName, options?.themeId, mountMode);
525
+ if (agent) {
526
+ return {
527
+ name: agentName,
528
+ baseName,
529
+ worktreePath: agentDir,
530
+ agent
531
+ };
532
+ }
533
+ // Name collision at DB level — clean up on-disk artifacts and retry
534
+ log?.(`⚠️ Name collision for "${agentName}" (attempt ${attempt + 1}/${EPHEMERAL_CREATE_MAX_RETRIES + 1}), retrying with a new name...`);
535
+ cleanupFailedEphemeralAgent(agentDir, workspaceInfo, mountMode);
536
+ }
537
+ // All retries exhausted
538
+ throw new Error(`Failed to create ephemeral agent after ${EPHEMERAL_CREATE_MAX_RETRIES + 1} attempts due to concurrent name collisions. ` +
539
+ `This can happen when many ephemeral agents are being created simultaneously. ` +
540
+ `Suggested remediation: wait a moment and retry, or specify a unique agent name with --agent.`);
541
+ }
542
+ /**
543
+ * Clean up on-disk artifacts (directories, worktrees) for a failed ephemeral
544
+ * agent creation attempt. Used when a DB name collision forces a retry.
545
+ */
546
+ function cleanupFailedEphemeralAgent(agentDir, workspaceInfo, mountMode) {
547
+ if (!fs.existsSync(agentDir))
548
+ return;
549
+ // Remove git worktrees first (if in worktree mode)
550
+ if (mountMode === 'worktree') {
551
+ const reposPath = path.join(workspaceInfo.path, 'repos');
552
+ if (fs.existsSync(reposPath)) {
553
+ for (const repo of workspaceInfo.repositories) {
554
+ const sourceRepoPath = path.join(reposPath, repo.name);
555
+ const worktreePath = path.join(agentDir, repo.name);
556
+ if (fs.existsSync(sourceRepoPath) && fs.existsSync(worktreePath)) {
488
557
  try {
489
- // Create git worktree for the repository
490
- // Don't create a branch yet - that happens in work:start
491
- // Use --detach to create without a branch reference
492
- execSync(`git worktree add --detach "${targetPath}"`, {
558
+ execSync(`git worktree remove "${worktreePath}" --force`, {
493
559
  cwd: sourceRepoPath,
494
560
  stdio: 'pipe'
495
561
  });
496
562
  }
497
563
  catch {
498
- // If worktree creation fails, try to just create the directory
499
- // The agent can still work without a worktree (e.g., for non-git projects)
500
- if (!fs.existsSync(targetPath)) {
501
- fs.mkdirSync(targetPath, { recursive: true });
502
- }
564
+ // Ignore rmSync below will clean up the directory
503
565
  }
504
566
  }
505
567
  }
506
568
  }
507
569
  }
508
- // Create devcontainer config if not skipped (uses shared devcontainer generator)
509
- if (!options?.skipDevcontainer) {
510
- const devcontainerDir = path.join(agentDir, '.devcontainer');
511
- if (!fs.existsSync(devcontainerDir)) {
512
- const gitIdentity = getGitIdentity();
513
- createDevcontainerConfig({
514
- agentName,
515
- agentDir,
516
- repoWorktrees: mountMode === 'worktree' ? workspaceInfo.repositories.map(r => r.name) : undefined,
517
- mountMode,
518
- gitUserName: gitIdentity.name || undefined,
519
- gitUserEmail: gitIdentity.email || undefined,
520
- });
521
- }
570
+ // Remove the agent directory
571
+ try {
572
+ fs.rmSync(agentDir, { recursive: true, force: true });
573
+ }
574
+ catch {
575
+ // Best-effort cleanup
522
576
  }
523
- // Add to database
524
- const agent = addEphemeralAgentToDatabase(workspaceInfo.path, agentName, baseName, options?.themeId, mountMode);
525
- return {
526
- name: agentName,
527
- baseName,
528
- worktreePath: agentDir,
529
- agent
530
- };
531
577
  }
532
578
  /**
533
579
  * Check if a tmux session exists for a given name