@proletariat/cli 0.3.7 → 0.3.9

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.
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Database operations for agent_work table.
5
5
  */
6
+ import { execSync } from 'node:child_process';
6
7
  import { PMO_TABLES } from '../pmo/schema.js';
7
8
  const T = PMO_TABLES;
8
9
  // =============================================================================
@@ -190,6 +191,93 @@ export class ExecutionStorage {
190
191
  .get(agentName);
191
192
  return count.count === 0;
192
193
  }
194
+ /**
195
+ * Clean up stale executions where the tmux session no longer exists.
196
+ * This fixes the bug where agents appear "busy" after sessions terminate unexpectedly.
197
+ * Returns the number of stale executions cleaned up.
198
+ */
199
+ cleanupStaleExecutions() {
200
+ // Get all "running" or "starting" executions
201
+ const activeExecutions = this.listExecutions({ status: 'running' })
202
+ .concat(this.listExecutions({ status: 'starting' }));
203
+ if (activeExecutions.length === 0) {
204
+ return 0;
205
+ }
206
+ // Get list of actual tmux sessions on host
207
+ const hostTmuxSessions = this.getHostTmuxSessionNames();
208
+ // Get map of container -> tmux sessions
209
+ const containerTmuxSessions = this.getContainerTmuxSessionMap();
210
+ let cleanedCount = 0;
211
+ for (const exec of activeExecutions) {
212
+ if (!exec.sessionId) {
213
+ // Executions without sessionId might be stale from early termination
214
+ // Check if they're older than 5 minutes and mark as stopped
215
+ const ageMs = Date.now() - exec.startedAt.getTime();
216
+ if (ageMs > 5 * 60 * 1000) {
217
+ this.updateStatus(exec.id, 'stopped');
218
+ cleanedCount++;
219
+ }
220
+ continue;
221
+ }
222
+ let sessionExists = false;
223
+ if (exec.environment === 'devcontainer' && exec.containerId) {
224
+ // Check if session exists in container
225
+ const containerSessions = containerTmuxSessions.get(exec.containerId);
226
+ sessionExists = containerSessions?.includes(exec.sessionId) ?? false;
227
+ }
228
+ else {
229
+ // Check if session exists on host
230
+ sessionExists = hostTmuxSessions.includes(exec.sessionId);
231
+ }
232
+ if (!sessionExists) {
233
+ // Session doesn't exist, mark execution as stopped
234
+ this.updateStatus(exec.id, 'stopped');
235
+ cleanedCount++;
236
+ }
237
+ }
238
+ return cleanedCount;
239
+ }
240
+ /**
241
+ * Get list of host tmux session names
242
+ */
243
+ getHostTmuxSessionNames() {
244
+ try {
245
+ execSync('which tmux', { stdio: 'pipe' });
246
+ const output = execSync('tmux list-sessions -F "#{session_name}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
247
+ if (!output)
248
+ return [];
249
+ return output.split('\n');
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ }
255
+ /**
256
+ * Get map of containerId -> tmux session names
257
+ */
258
+ getContainerTmuxSessionMap() {
259
+ const sessionMap = new Map();
260
+ try {
261
+ const containersOutput = execSync('docker ps --filter "label=devcontainer.local_folder" --format "{{.ID}}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
262
+ if (!containersOutput)
263
+ return sessionMap;
264
+ for (const containerId of containersOutput.split('\n')) {
265
+ try {
266
+ const tmuxOutput = execSync(`docker exec ${containerId} tmux list-sessions -F "#{session_name}" 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
267
+ if (tmuxOutput) {
268
+ sessionMap.set(containerId, tmuxOutput.split('\n'));
269
+ }
270
+ }
271
+ catch {
272
+ // Container has no tmux sessions
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ // Docker not available
278
+ }
279
+ return sessionMap;
280
+ }
193
281
  /**
194
282
  * Get total execution count for an agent (historical)
195
283
  * Used by least-busy agent selection strategy.
@@ -1,3 +1,4 @@
1
+ import { SQLiteStorage } from './storage-sqlite.js';
1
2
  export * from './types.js';
2
3
  export * from './utils.js';
3
4
  export { parseBoard, generateBoardMarkdown, findAddedTickets, findRemovedTickets, findModifiedTickets, } from './markdown.js';
@@ -9,26 +10,14 @@ export { findPMO } from './find-pmo.js';
9
10
  export { getPMOContext, type PMOContext, type GetPMOContextOptions } from './pmo-context.js';
10
11
  export { PMOCommand, pmoBaseFlags } from './base-command.js';
11
12
  export { PMO_TABLES, PMO_TABLE_SCHEMAS, PMO_INDEXES, PMO_SCHEMA_SQL, EXPECTED_TICKET_COLUMNS, validateTicketSchema, } from './schema.js';
13
+ export { BUILTIN_TEMPLATES, getBuiltinTemplate, getPickerTemplates, getColumnsForTemplate, getColumnSettingsForTemplate, } from './templates-builtin.js';
12
14
  /**
13
- * Get available board templates
15
+ * Get available board templates (backward compatibility wrapper).
16
+ * @deprecated Use BUILTIN_TEMPLATES or getColumnsForTemplate() instead
14
17
  */
15
18
  export declare function getBoardTemplates(): {
16
19
  [key: string]: string[];
17
20
  };
18
- /**
19
- * Get column settings for work commands based on board template.
20
- * These settings determine which columns are used for:
21
- * - work start: moves ticket to column_in_progress
22
- * - work ready: moves ticket to column_review
23
- * - work complete: moves ticket to column_done
24
- *
25
- * For custom templates, we try to find matching columns by keyword.
26
- */
27
- export declare function getColumnSettingsForTemplate(template: string, columns: string[]): {
28
- column_planned: string;
29
- column_in_progress: string;
30
- column_done: string;
31
- };
32
21
  export type PMOStorageType = 'sqlite' | 'git';
33
22
  export type PMOLocation = 'separate' | `repo:${string}`;
34
23
  export interface PMOSetupResult {
@@ -51,9 +40,10 @@ export declare function detectRepos(hqRoot: string): Array<{
51
40
  */
52
41
  export declare function promptForPMOLocation(hqRoot: string | null): Promise<PMOLocation>;
53
42
  /**
54
- * Prompt for board template
43
+ * Prompt for board template.
44
+ * Uses BUILTIN_TEMPLATES as the single source of truth.
55
45
  */
56
- export declare function promptForBoardTemplate(): Promise<string>;
46
+ export declare function promptForBoardTemplate(_storage?: SQLiteStorage): Promise<string>;
57
47
  /**
58
48
  * Prompt for custom columns
59
49
  */
@@ -68,10 +58,6 @@ export declare function promptForBoardName(defaultName?: string): Promise<string
68
58
  * PMO is included by default (no prompt) per TKT-469 requirements.
69
59
  */
70
60
  export declare function promptForPMOSetup(hqRoot: string | null, hqName?: string): Promise<PMOSetupResult>;
71
- /**
72
- * Get columns for a board template
73
- */
74
- export declare function getColumnsForTemplate(template: string): string[];
75
61
  /**
76
62
  * Create board content for Obsidian Kanban
77
63
  */
@@ -18,94 +18,20 @@ export { findPMO } from './find-pmo.js';
18
18
  export { getPMOContext } from './pmo-context.js';
19
19
  export { PMOCommand, pmoBaseFlags } from './base-command.js';
20
20
  export { PMO_TABLES, PMO_TABLE_SCHEMAS, PMO_INDEXES, PMO_SCHEMA_SQL, EXPECTED_TICKET_COLUMNS, validateTicketSchema, } from './schema.js';
21
+ // Re-export template utilities from shared definitions
22
+ export { BUILTIN_TEMPLATES, getBuiltinTemplate, getPickerTemplates, getColumnsForTemplate, getColumnSettingsForTemplate, } from './templates-builtin.js';
23
+ import { BUILTIN_TEMPLATES, getColumnsForTemplate, getColumnSettingsForTemplate, } from './templates-builtin.js';
21
24
  /**
22
- * Get available board templates
25
+ * Get available board templates (backward compatibility wrapper).
26
+ * @deprecated Use BUILTIN_TEMPLATES or getColumnsForTemplate() instead
23
27
  */
24
28
  export function getBoardTemplates() {
25
- return {
26
- // Linear-style: Simple workflow with Planned for scheduled work
27
- kanban: ['Backlog', 'Planned', 'In Progress', 'Done'],
28
- // Linear-style with Triage and Review columns
29
- linear: ['Backlog', 'Triage', 'Todo', 'In Progress', 'In Review', 'Done'],
30
- // Bug tracking workflow
31
- 'bug-smash': ['Reported', 'Confirmed', 'Fixing', 'Verifying', 'Fixed'],
32
- // Founder template: 5-tool backlogs (by work type) + workflow stages
33
- '5-tool-founder': [
34
- 'Ship', 'Grow', 'Support', 'Strategy', 'BizOps',
35
- 'In Progress', 'Review', 'Done'
36
- ],
37
- // Go-to-market workflow
38
- 'gtm': ['Ideation', 'Planning', 'In Development', 'Ready to Launch', 'Launched'],
39
- // Legacy alias for founder
40
- founder: [
41
- 'SHIP BL', 'GROW BL', 'SUPPORT BL', 'BIZOPS BL', 'STRATEGY BL',
42
- 'Planned', 'In Progress', 'Done', 'Dropped'
43
- ],
44
- custom: [] // Will be handled separately
45
- };
46
- }
47
- /**
48
- * Get column settings for work commands based on board template.
49
- * These settings determine which columns are used for:
50
- * - work start: moves ticket to column_in_progress
51
- * - work ready: moves ticket to column_review
52
- * - work complete: moves ticket to column_done
53
- *
54
- * For custom templates, we try to find matching columns by keyword.
55
- */
56
- export function getColumnSettingsForTemplate(template, columns) {
57
- // Template-specific mappings (Linear-style: planned -> in_progress -> done)
58
- const templateMappings = {
59
- kanban: {
60
- column_planned: 'Planned',
61
- column_in_progress: 'In Progress',
62
- column_done: 'Done',
63
- },
64
- linear: {
65
- column_planned: 'Todo',
66
- column_in_progress: 'In Progress',
67
- column_done: 'Done',
68
- },
69
- 'bug-smash': {
70
- column_planned: 'Confirmed',
71
- column_in_progress: 'Fixing',
72
- column_done: 'Fixed',
73
- },
74
- '5-tool-founder': {
75
- column_planned: 'Ship',
76
- column_in_progress: 'In Progress',
77
- column_done: 'Done',
78
- },
79
- 'gtm': {
80
- column_planned: 'Planning',
81
- column_in_progress: 'In Development',
82
- column_done: 'Launched',
83
- },
84
- founder: {
85
- column_planned: 'Planned',
86
- column_in_progress: 'In Progress',
87
- column_done: 'Done',
88
- },
89
- };
90
- // Use template mapping if available
91
- if (templateMappings[template]) {
92
- return templateMappings[template];
29
+ const result = {};
30
+ for (const template of BUILTIN_TEMPLATES) {
31
+ result[template.id] = template.columns;
93
32
  }
94
- // For custom templates, try to find matching columns by keyword
95
- const findColumn = (keywords, fallback) => {
96
- const lowerColumns = columns.map(c => c.toLowerCase());
97
- for (const keyword of keywords) {
98
- const idx = lowerColumns.findIndex(c => c.includes(keyword));
99
- if (idx !== -1)
100
- return columns[idx];
101
- }
102
- return fallback;
103
- };
104
- return {
105
- column_planned: findColumn(['planned', 'ready', 'scheduled', 'todo'], columns[1] || 'Planned'),
106
- column_in_progress: findColumn(['progress', 'active', 'doing', 'working'], columns[2] || 'In Progress'),
107
- column_done: findColumn(['done', 'complete', 'finished', 'published', 'shipped'], columns[columns.length - 1] || 'Done'),
108
- };
33
+ result['custom'] = []; // Special case for custom templates
34
+ return result;
109
35
  }
110
36
  /**
111
37
  * Detect repositories in an HQ
@@ -175,21 +101,23 @@ export async function promptForPMOLocation(hqRoot) {
175
101
  }
176
102
  }
177
103
  /**
178
- * Prompt for board template
104
+ * Prompt for board template.
105
+ * Uses BUILTIN_TEMPLATES as the single source of truth.
179
106
  */
180
- export async function promptForBoardTemplate() {
107
+ export async function promptForBoardTemplate(_storage) {
108
+ // Use builtin templates directly - single source of truth
109
+ const pickerTemplates = BUILTIN_TEMPLATES.filter(t => t.showInPicker);
110
+ const choices = pickerTemplates.map(t => ({
111
+ name: `${t.name} (${t.columns.slice(0, 4).join(', ')}${t.columns.length > 4 ? '...' : ''})`,
112
+ value: t.id,
113
+ }));
114
+ // Add custom option
115
+ choices.push({ name: 'Custom (define your own columns)', value: 'custom' });
181
116
  const { template } = await inquirer.prompt([{
182
117
  type: 'list',
183
118
  name: 'template',
184
119
  message: 'Choose board template:',
185
- choices: [
186
- { name: 'Kanban - Backlog → Planned → In Progress → Done', value: 'kanban' },
187
- { name: 'Linear - Backlog, Triage, Todo, In Progress, In Review, Done', value: 'linear' },
188
- { name: 'Bug Smash - Reported → Confirmed → Fixing → Verifying → Fixed', value: 'bug-smash' },
189
- { name: '5-Tool Founder - Ship, Grow, Support, Strategy, BizOps → In Progress → Review → Done', value: '5-tool-founder' },
190
- { name: 'GTM - Ideation → Planning → In Development → Ready to Launch → Launched', value: 'gtm' },
191
- { name: 'Custom (define your own columns)', value: 'custom' },
192
- ],
120
+ choices,
193
121
  default: 'kanban',
194
122
  }]);
195
123
  return template;
@@ -253,13 +181,6 @@ export async function promptForPMOSetup(hqRoot, hqName) {
253
181
  columns,
254
182
  };
255
183
  }
256
- /**
257
- * Get columns for a board template
258
- */
259
- export function getColumnsForTemplate(template) {
260
- const templates = getBoardTemplates();
261
- return templates[template] || templates.kanban;
262
- }
263
184
  /**
264
185
  * Create board content for Obsidian Kanban
265
186
  */
@@ -13,8 +13,8 @@ export declare function initializePMOTables(db: Database.Database): void;
13
13
  */
14
14
  export declare function runMigrations(db: Database.Database): void;
15
15
  /**
16
- * Seed built-in workflows (shared workflow definitions).
17
- * Creates workflows from existing templates for reuse across projects.
16
+ * Seed built-in workflows from BUILTIN_TEMPLATES (single source of truth).
17
+ * Creates workflows from template definitions for reuse across projects.
18
18
  */
19
19
  export declare function seedBuiltinWorkflows(db: Database.Database): void;
20
20
  /**
@@ -3,6 +3,7 @@
3
3
  * This module handles database setup and provides shared utilities.
4
4
  */
5
5
  import { PMO_TABLES, PMO_SCHEMA_SQL, validateTicketSchema } from '../schema.js';
6
+ import { BUILTIN_TEMPLATES } from '../templates-builtin.js';
6
7
  const T = PMO_TABLES;
7
8
  /**
8
9
  * Initialize PMO tables in the database.
@@ -218,92 +219,10 @@ export function runMigrations(db) {
218
219
  }
219
220
  }
220
221
  /**
221
- * Seed built-in workflows (shared workflow definitions).
222
- * Creates workflows from existing templates for reuse across projects.
222
+ * Seed built-in workflows from BUILTIN_TEMPLATES (single source of truth).
223
+ * Creates workflows from template definitions for reuse across projects.
223
224
  */
224
225
  export function seedBuiltinWorkflows(db) {
225
- // Define built-in workflows based on the template definitions
226
- const builtinWorkflows = [
227
- {
228
- id: 'default',
229
- name: 'Default',
230
- description: 'Default workflow: Backlog → Ready → In Progress → Review → Done',
231
- statuses: [
232
- { name: 'Backlog', category: 'backlog', position: 0, isDefault: true },
233
- { name: 'Ready', category: 'unstarted', position: 1 },
234
- { name: 'In Progress', category: 'started', position: 2 },
235
- { name: 'Review', category: 'started', position: 3 },
236
- { name: 'Done', category: 'completed', position: 4 },
237
- ],
238
- },
239
- {
240
- id: 'kanban',
241
- name: 'Kanban',
242
- description: 'Simple kanban workflow: Backlog → To Do → In Progress → Done',
243
- statuses: [
244
- { name: 'Backlog', category: 'backlog', position: 0, isDefault: true },
245
- { name: 'To Do', category: 'unstarted', position: 1 },
246
- { name: 'In Progress', category: 'started', position: 2 },
247
- { name: 'Done', category: 'completed', position: 3 },
248
- { name: 'Canceled', category: 'canceled', position: 4 },
249
- ],
250
- },
251
- {
252
- id: 'linear',
253
- name: 'Linear',
254
- description: 'Linear-style workflow with backlog, triage, and review stages',
255
- statuses: [
256
- { name: 'Backlog', category: 'backlog', position: 0, isDefault: true },
257
- { name: 'Triage', category: 'backlog', position: 1 },
258
- { name: 'Todo', category: 'unstarted', position: 2 },
259
- { name: 'In Progress', category: 'started', position: 3 },
260
- { name: 'In Review', category: 'started', position: 4 },
261
- { name: 'Done', category: 'completed', position: 5 },
262
- { name: 'Canceled', category: 'canceled', position: 6 },
263
- ],
264
- },
265
- {
266
- id: 'bug-smash',
267
- name: 'Bug Smash',
268
- description: 'Bug tracking workflow with verification stages',
269
- statuses: [
270
- { name: 'Reported', category: 'backlog', position: 0, isDefault: true },
271
- { name: 'Confirmed', category: 'unstarted', position: 1 },
272
- { name: 'Fixing', category: 'started', position: 2 },
273
- { name: 'Verifying', category: 'started', position: 3 },
274
- { name: 'Fixed', category: 'completed', position: 4 },
275
- { name: "Won't Fix", category: 'canceled', position: 5 },
276
- ],
277
- },
278
- {
279
- id: '5-tool-founder',
280
- name: '5-Tool Founder',
281
- description: 'Founder workflow: Ship, Grow, Support, Strategy, BizOps backlogs → In Progress → Review → Done',
282
- statuses: [
283
- { name: 'Ship', category: 'backlog', position: 0, isDefault: true },
284
- { name: 'Grow', category: 'backlog', position: 1 },
285
- { name: 'Support', category: 'backlog', position: 2 },
286
- { name: 'Strategy', category: 'backlog', position: 3 },
287
- { name: 'BizOps', category: 'backlog', position: 4 },
288
- { name: 'In Progress', category: 'started', position: 5 },
289
- { name: 'Review', category: 'started', position: 6 },
290
- { name: 'Done', category: 'completed', position: 7 },
291
- ],
292
- },
293
- {
294
- id: 'gtm',
295
- name: 'GTM',
296
- description: 'Go-to-market workflow for launches and campaigns',
297
- statuses: [
298
- { name: 'Ideation', category: 'backlog', position: 0, isDefault: true },
299
- { name: 'Planning', category: 'unstarted', position: 1 },
300
- { name: 'In Development', category: 'started', position: 2 },
301
- { name: 'Ready to Launch', category: 'started', position: 3 },
302
- { name: 'Launched', category: 'completed', position: 4 },
303
- { name: 'Retired', category: 'canceled', position: 5 },
304
- ],
305
- },
306
- ];
307
226
  const now = new Date().toISOString();
308
227
  const insertWorkflow = db.prepare(`
309
228
  INSERT OR IGNORE INTO ${T.workflows} (id, name, description, is_builtin, created_at, updated_at)
@@ -313,11 +232,17 @@ export function seedBuiltinWorkflows(db) {
313
232
  INSERT OR IGNORE INTO ${T.workflow_statuses} (id, workflow_id, name, category, position, color, description, is_default, created_at)
314
233
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
315
234
  `);
316
- for (const workflow of builtinWorkflows) {
317
- insertWorkflow.run(workflow.id, workflow.name, workflow.description, now, now);
318
- for (const status of workflow.statuses) {
319
- const statusId = `${workflow.id}-${status.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')}`;
320
- insertStatus.run(statusId, workflow.id, status.name, status.category, status.position, status.color || null, null, status.isDefault ? 1 : 0, now);
235
+ // Read from BUILTIN_TEMPLATES - the single source of truth
236
+ for (const template of BUILTIN_TEMPLATES) {
237
+ insertWorkflow.run(template.id, template.name, template.description, now, now);
238
+ for (let i = 0; i < template.statuses.length; i++) {
239
+ const status = template.statuses[i];
240
+ const statusId = `${template.id}-${status.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')}`;
241
+ // First status is the default
242
+ const isDefault = i === 0;
243
+ insertStatus.run(statusId, template.id, status.name, status.category, status.position, null, // color
244
+ null, // description
245
+ isDefault ? 1 : 0, now);
321
246
  }
322
247
  }
323
248
  // Assign default workflow to any projects without a workflow
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Builtin workflow templates - single source of truth.
3
+ *
4
+ * This file defines all builtin templates used by:
5
+ * - UI prompts (template selection menu)
6
+ * - Database seeding (pmo_templates table)
7
+ * - Board column creation
8
+ * - Work command column mappings
9
+ */
10
+ import { StateCategory } from './types.js';
11
+ export interface WorkflowTemplateStatus {
12
+ name: string;
13
+ category: StateCategory;
14
+ position: number;
15
+ }
16
+ export interface ColumnSettings {
17
+ column_planned: string;
18
+ column_in_progress: string;
19
+ column_done: string;
20
+ }
21
+ export interface BuiltinTemplate {
22
+ /** Unique identifier used in code and database */
23
+ id: string;
24
+ /** Display name shown in UI */
25
+ name: string;
26
+ /** Description of the workflow */
27
+ description: string;
28
+ /** Column names for the board UI */
29
+ columns: string[];
30
+ /** Workflow statuses for state tracking */
31
+ statuses: WorkflowTemplateStatus[];
32
+ /** Column mappings for work commands (start, ready, complete) */
33
+ columnSettings: ColumnSettings;
34
+ /** Whether to show in the init template picker (false for specialized templates) */
35
+ showInPicker: boolean;
36
+ }
37
+ /**
38
+ * All builtin workflow templates.
39
+ *
40
+ * This is the SINGLE SOURCE OF TRUTH for:
41
+ * - Database seeding (seedBuiltinWorkflows reads from this)
42
+ * - UI template picker
43
+ * - Board column creation
44
+ * - Work command column mappings
45
+ *
46
+ * To add a new template:
47
+ * 1. Add it to this array
48
+ * 2. That's it - UI and DB will pick it up automatically
49
+ */
50
+ export declare const BUILTIN_TEMPLATES: BuiltinTemplate[];
51
+ /**
52
+ * Get a template by ID.
53
+ */
54
+ export declare function getBuiltinTemplate(id: string): BuiltinTemplate | undefined;
55
+ /**
56
+ * Get templates that should be shown in the init picker.
57
+ */
58
+ export declare function getPickerTemplates(): BuiltinTemplate[];
59
+ /**
60
+ * Get columns for a template (for backward compatibility).
61
+ */
62
+ export declare function getColumnsForTemplate(templateId: string): string[];
63
+ /**
64
+ * Get column settings for a template (for backward compatibility).
65
+ */
66
+ export declare function getColumnSettingsForTemplate(templateId: string, columns: string[]): ColumnSettings;