@proletariat/cli 0.3.98 → 0.3.100

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 (48) hide show
  1. package/dist/commands/orchestrate/index.js +30 -3
  2. package/dist/commands/orchestrate/index.js.map +1 -1
  3. package/dist/commands/qa/index.js +1 -1
  4. package/dist/commands/ticket/create.d.ts +44 -0
  5. package/dist/commands/ticket/create.js +754 -0
  6. package/dist/commands/ticket/create.js.map +1 -0
  7. package/dist/commands/ticket/delete.d.ts +17 -0
  8. package/dist/commands/ticket/delete.js +204 -0
  9. package/dist/commands/ticket/delete.js.map +1 -0
  10. package/dist/commands/ticket/edit.d.ts +28 -0
  11. package/dist/commands/ticket/edit.js +402 -0
  12. package/dist/commands/ticket/edit.js.map +1 -0
  13. package/dist/commands/ticket/index.d.ts +13 -0
  14. package/dist/commands/ticket/index.js +74 -0
  15. package/dist/commands/ticket/index.js.map +1 -0
  16. package/dist/commands/ticket/list.d.ts +33 -0
  17. package/dist/commands/ticket/list.js +519 -0
  18. package/dist/commands/ticket/list.js.map +1 -0
  19. package/dist/commands/ticket/move.d.ts +27 -0
  20. package/dist/commands/ticket/move.js +413 -0
  21. package/dist/commands/ticket/move.js.map +1 -0
  22. package/dist/commands/ticket/show.d.ts +14 -0
  23. package/dist/commands/ticket/show.js +110 -0
  24. package/dist/commands/ticket/show.js.map +1 -0
  25. package/dist/commands/ticket/update.d.ts +28 -0
  26. package/dist/commands/ticket/update.js +458 -0
  27. package/dist/commands/ticket/update.js.map +1 -0
  28. package/dist/lib/execution/preflight.js +1 -1
  29. package/dist/lib/execution/preflight.js.map +1 -1
  30. package/dist/lib/mcp/tools/action.d.ts +6 -0
  31. package/dist/lib/mcp/tools/action.js +123 -0
  32. package/dist/lib/mcp/tools/action.js.map +1 -0
  33. package/dist/lib/mcp/tools/index.d.ts +2 -0
  34. package/dist/lib/mcp/tools/index.js +2 -0
  35. package/dist/lib/mcp/tools/index.js.map +1 -1
  36. package/dist/lib/mcp/tools/ticket.d.ts +6 -0
  37. package/dist/lib/mcp/tools/ticket.js +464 -0
  38. package/dist/lib/mcp/tools/ticket.js.map +1 -0
  39. package/dist/lib/orchestrate/poller.d.ts +22 -0
  40. package/dist/lib/orchestrate/poller.js +109 -0
  41. package/dist/lib/orchestrate/poller.js.map +1 -1
  42. package/dist/lib/sync/engine.js +47 -5
  43. package/dist/lib/sync/engine.js.map +1 -1
  44. package/dist/lib/sync/reconciler.d.ts +27 -1
  45. package/dist/lib/sync/reconciler.js +109 -1
  46. package/dist/lib/sync/reconciler.js.map +1 -1
  47. package/oclif.manifest.json +2021 -1153
  48. package/package.json +1 -1
@@ -0,0 +1,754 @@
1
+ import { Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs';
3
+ import inquirer from 'inquirer';
4
+ import { autoExportToBoard, PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
5
+ // Note: inquirer import kept for inquirer.Separator usage in interactive mode
6
+ import { styles } from '../../lib/styles.js';
7
+ import { updateEpicTicketsSection } from '../../lib/pmo/epic-files.js';
8
+ import { getWorkspacePriorities } from '../../lib/work-lifecycle/settings.js';
9
+ import { shouldOutputJson, outputErrorAsJson, outputDryRunSuccessAsJson, outputDryRunErrorsAsJson, createMetadata, } from '../../lib/prompt-json.js';
10
+ import { FlagResolver } from '../../lib/flags/index.js';
11
+ import { multiLineInput } from '../../lib/multiline-input.js';
12
+ import { getRegisteredWorkSources, loadDefaultWorkSource } from '../../lib/work-source/config.js';
13
+ export default class TicketCreate extends PMOCommand {
14
+ static description = 'Create a new ticket (routes to Linear when configured, or local PMO)';
15
+ static examples = [
16
+ '<%= config.bin %> <%= command.id %>',
17
+ '<%= config.bin %> <%= command.id %> --title "Fix login bug" --column Backlog',
18
+ '<%= config.bin %> <%= command.id %> -t "Add feature" -c "In Progress" -p P1',
19
+ '<%= config.bin %> <%= command.id %> --project mobile-app -t "New feature"',
20
+ '<%= config.bin %> <%= command.id %> --epic EPIC-001 -t "Implement auth flow"',
21
+ '<%= config.bin %> <%= command.id %> --title "My ticket" --description-file ./ticket-desc.md',
22
+ '<%= config.bin %> <%= command.id %> --title "My ticket" --description-file - # Read from stdin',
23
+ '<%= config.bin %> <%= command.id %> --json # Output column choices as JSON',
24
+ '<%= config.bin %> <%= command.id %> --title "Test" -P PROJ-001 --dry-run --json # Validate without creating',
25
+ '<%= config.bin %> <%= command.id %> --source linear -t "Fix bug" --team ENG',
26
+ '<%= config.bin %> <%= command.id %> --source pmo -t "Local task" -c Backlog',
27
+ ];
28
+ static flags = {
29
+ ...pmoBaseFlags,
30
+ title: Flags.string({
31
+ char: 't',
32
+ description: 'Ticket title [required for non-interactive]',
33
+ }),
34
+ column: Flags.string({
35
+ char: 'c',
36
+ description: 'Column to place the ticket in',
37
+ }),
38
+ priority: Flags.string({
39
+ char: 'p',
40
+ description: 'Ticket priority (uses workspace priority scale)',
41
+ }),
42
+ category: Flags.string({
43
+ description: 'Ticket category (e.g., bug, feature, refactor)',
44
+ }),
45
+ description: Flags.string({
46
+ char: 'd',
47
+ description: 'Ticket description',
48
+ }),
49
+ 'description-file': Flags.string({
50
+ char: 'D',
51
+ description: 'Path to a markdown file for the ticket description (use - for stdin)',
52
+ exclusive: ['description'],
53
+ }),
54
+ id: Flags.string({
55
+ description: 'Custom ticket ID (auto-generated if not provided)',
56
+ }),
57
+ interactive: Flags.boolean({
58
+ char: 'i',
59
+ description: 'Interactive mode',
60
+ default: false,
61
+ }),
62
+ epic: Flags.string({
63
+ char: 'e',
64
+ description: 'Link ticket to an epic (e.g., EPIC-001)',
65
+ }),
66
+ template: Flags.string({
67
+ char: 'T',
68
+ description: 'Create from a template (e.g., bug-report, feature-request)',
69
+ }),
70
+ labels: Flags.string({
71
+ char: 'l',
72
+ aliases: ['label'],
73
+ description: 'Labels (comma-separated)',
74
+ }),
75
+ 'dry-run': Flags.boolean({
76
+ description: 'Validate inputs without creating ticket (use with --json for structured output)',
77
+ default: false,
78
+ }),
79
+ source: Flags.string({
80
+ description: 'Ticket source: "pmo" for local DB, "linear" for Linear API, or "auto" to detect (default: auto)',
81
+ options: ['auto', 'pmo', 'linear'],
82
+ default: 'auto',
83
+ }),
84
+ team: Flags.string({
85
+ description: 'Linear team key (fallback: PRLT_LINEAR_TEAM)',
86
+ }),
87
+ };
88
+ async execute() {
89
+ const { flags } = await this.parse(TicketCreate);
90
+ // Check if JSON output mode is active
91
+ const jsonMode = shouldOutputJson(flags);
92
+ // Read description from file/stdin EARLY — before any prompts or routing
93
+ // that could consume stdin or error out before the read happens.
94
+ if (flags['description-file']) {
95
+ const filePath = flags['description-file'];
96
+ try {
97
+ if (filePath === '-') {
98
+ if (process.stdin.isTTY) {
99
+ if (jsonMode) {
100
+ outputErrorAsJson('DESCRIPTION_FILE_ERROR', 'Cannot read from stdin: no input piped. Use --description-file <path> with a file path instead, or pipe content via: echo "desc" | prlt ticket create --description-file -', createMetadata('ticket create', flags));
101
+ return;
102
+ }
103
+ this.error('Cannot read from stdin: no input piped. Use --description-file <path> with a file path instead, or pipe content via: echo "desc" | prlt ticket create --description-file -');
104
+ }
105
+ flags.description = fs.readFileSync(0, 'utf-8');
106
+ }
107
+ else {
108
+ flags.description = fs.readFileSync(filePath, 'utf-8');
109
+ }
110
+ }
111
+ catch (error) {
112
+ const errMsg = error instanceof Error ? error.message : String(error);
113
+ if (jsonMode) {
114
+ outputErrorAsJson('DESCRIPTION_FILE_ERROR', `Failed to read description file "${filePath}": ${errMsg}`, createMetadata('ticket create', flags));
115
+ return;
116
+ }
117
+ this.error(`Failed to read description file "${filePath}": ${errMsg}`);
118
+ }
119
+ }
120
+ // Determine ticket source (pmo, linear, or prompt user)
121
+ const resolvedSource = await this.resolveSource(flags, jsonMode);
122
+ // If Linear source is selected, delegate to the Linear creation path
123
+ if (resolvedSource === 'linear') {
124
+ return this.createLinearIssue(flags, jsonMode);
125
+ }
126
+ // PMO path — existing flow
127
+ // Get project and board info (pass JSON mode config for AI agents)
128
+ const projectId = await this.requireProject({
129
+ jsonMode: {
130
+ flags,
131
+ commandName: 'ticket create',
132
+ baseCommand: 'prlt ticket create',
133
+ },
134
+ });
135
+ const board = await this.storage.getBoard(projectId);
136
+ const columns = board.columns.map(c => c.name);
137
+ const projectName = await this.getProjectName(projectId);
138
+ // Helper to handle errors in JSON mode
139
+ const handleError = (code, message) => {
140
+ if (jsonMode) {
141
+ outputErrorAsJson(code, message, createMetadata('ticket create', flags));
142
+ return;
143
+ }
144
+ this.error(message);
145
+ };
146
+ // NOTE: --description-file handling is done early in execute(), before routing.
147
+ // flags.description is already populated if --description-file was provided.
148
+ // Validate epic if provided
149
+ if (flags.epic) {
150
+ const epic = await this.storage.getEpic(flags.epic);
151
+ if (!epic) {
152
+ return handleError('EPIC_NOT_FOUND', `Epic not found: ${flags.epic}. Use 'prlt epic list' to see available epics.`);
153
+ }
154
+ }
155
+ // Load template if specified
156
+ let template = null;
157
+ if (flags.template) {
158
+ template = await this.storage.getTicketTemplate(flags.template);
159
+ if (!template) {
160
+ return handleError('TEMPLATE_NOT_FOUND', `Template not found: ${flags.template}. Run 'prlt ticket template list' to see available templates.`);
161
+ }
162
+ }
163
+ // Parse labels from flag
164
+ const labelsFromFlag = flags.labels
165
+ ? flags.labels.split(',').map(l => l.trim()).filter(Boolean)
166
+ : undefined;
167
+ // Get ticket data (interactive or from flags)
168
+ let ticketData;
169
+ // Use FlagResolver to handle both JSON mode and interactive prompts
170
+ // This unifies the two code paths into one pattern
171
+ if (!flags.interactive) {
172
+ // In JSON mode, default column to first backlog status if not provided
173
+ // This prevents prompting for column in non-interactive mode
174
+ if (jsonMode && !flags.column) {
175
+ // Prefer "Backlog" column, fall back to first column
176
+ const backlogColumn = columns.find(c => c.toLowerCase() === 'backlog') || columns[0];
177
+ flags.column = backlogColumn;
178
+ }
179
+ const resolver = new FlagResolver({
180
+ commandName: 'ticket create',
181
+ baseCommand: 'prlt ticket create',
182
+ jsonMode,
183
+ flags,
184
+ context: { projectId },
185
+ });
186
+ // Column selection - prompted first if missing
187
+ resolver.addPrompt({
188
+ flagName: 'column',
189
+ type: 'list',
190
+ message: 'Select column to place the ticket in:',
191
+ choices: () => columns.map(c => ({ name: c, value: c })),
192
+ when: (ctx) => !ctx.flags.column,
193
+ });
194
+ // Title input - prompted after column is set
195
+ resolver.addPrompt({
196
+ flagName: 'title',
197
+ type: 'input',
198
+ message: 'Enter ticket title:',
199
+ when: (ctx) => !ctx.flags.title && ctx.flags.column !== undefined,
200
+ validate: (value) => value.trim() ? true : 'Title cannot be empty',
201
+ context: (ctx) => ({
202
+ hint: `Provide title with: ${ctx.baseCommand}${ctx.projectId ? ` -P ${ctx.projectId}` : ''} --column "${ctx.flags.column}" --title "Your title here"`,
203
+ requiredFields: ['--title'],
204
+ optionalFields: ['--priority', '--category', '--description', '--epic', '--labels'],
205
+ example: `${ctx.baseCommand}${ctx.projectId ? ` -P ${ctx.projectId}` : ''} --column "${ctx.flags.column}" --title "Fix login bug" --priority P1 --category bug`,
206
+ }),
207
+ });
208
+ // Resolve missing flags (in JSON mode, outputs prompt and exits; in interactive mode, prompts user)
209
+ const resolvedFlags = await resolver.resolve();
210
+ // If we get here, we have both column and title
211
+ if (!resolvedFlags.title && !template?.titlePattern) {
212
+ return handleError('TITLE_REQUIRED', 'Title is required. Use --title or -t flag, or use --interactive mode.');
213
+ }
214
+ ticketData = {
215
+ title: resolvedFlags.title || template?.titlePattern || '',
216
+ statusName: resolvedFlags.column || columns[0],
217
+ priority: resolvedFlags.priority || template?.defaultPriority,
218
+ category: resolvedFlags.category || template?.defaultCategory,
219
+ description: resolvedFlags.description || template?.descriptionTemplate,
220
+ id: resolvedFlags.id,
221
+ epicId: resolvedFlags.epic,
222
+ labels: labelsFromFlag || template?.defaultLabels,
223
+ };
224
+ }
225
+ else {
226
+ // Full interactive mode - use the detailed prompts
227
+ ticketData = await this.promptTicketData(flags, this.storage, template, columns);
228
+ }
229
+ // Validate status/column
230
+ if (!columns.includes(ticketData.statusName)) {
231
+ if (flags['dry-run']) {
232
+ if (jsonMode) {
233
+ outputDryRunErrorsAsJson([{ field: 'column', error: `Invalid column "${ticketData.statusName}". Available: ${columns.join(', ')}` }], createMetadata('ticket create', flags));
234
+ return;
235
+ }
236
+ this.error(`Invalid column "${ticketData.statusName}". Available columns: ${columns.join(', ')}`);
237
+ }
238
+ return handleError('INVALID_COLUMN', `Invalid column "${ticketData.statusName}". Available columns: ${columns.join(', ')}`);
239
+ }
240
+ // Handle dry-run: show what would be created without actually creating
241
+ if (flags['dry-run']) {
242
+ const wouldCreate = {
243
+ title: ticketData.title,
244
+ project: projectId,
245
+ column: ticketData.statusName,
246
+ ...(ticketData.priority && { priority: ticketData.priority }),
247
+ ...(ticketData.category && { category: ticketData.category }),
248
+ ...(ticketData.description && { description: ticketData.description }),
249
+ ...(ticketData.epicId && { epic: ticketData.epicId }),
250
+ ...(ticketData.labels && ticketData.labels.length > 0 && { labels: ticketData.labels }),
251
+ };
252
+ if (jsonMode) {
253
+ outputDryRunSuccessAsJson('ticket', wouldCreate, createMetadata('ticket create', flags));
254
+ return;
255
+ }
256
+ // Human-readable dry-run output
257
+ this.log(styles.warning('\n[DRY RUN] Would create ticket:'));
258
+ this.log(styles.muted(` Title: ${ticketData.title}`));
259
+ this.log(styles.muted(` Project: ${projectName}`));
260
+ this.log(styles.muted(` Column: ${ticketData.statusName}`));
261
+ if (ticketData.priority) {
262
+ this.log(styles.muted(` Priority: ${ticketData.priority}`));
263
+ }
264
+ if (ticketData.category) {
265
+ this.log(styles.muted(` Category: ${ticketData.category}`));
266
+ }
267
+ if (ticketData.epicId) {
268
+ this.log(styles.muted(` Epic: ${ticketData.epicId}`));
269
+ }
270
+ if (ticketData.labels && ticketData.labels.length > 0) {
271
+ this.log(styles.muted(` Labels: ${ticketData.labels.join(', ')}`));
272
+ }
273
+ if (template) {
274
+ this.log(styles.muted(` Template: ${template.name}`));
275
+ if (template.suggestedSubtasks.length > 0) {
276
+ this.log(styles.muted(` Subtasks: ${template.suggestedSubtasks.length} would be created`));
277
+ }
278
+ }
279
+ this.log(styles.muted('\n(No ticket was created)'));
280
+ return;
281
+ }
282
+ // Create ticket through the provider (routes to configured backend)
283
+ const provider = this.resolveProjectProvider(projectId, 'pmo');
284
+ const createResult = await provider.createTicket(projectId, {
285
+ id: ticketData.id,
286
+ title: ticketData.title,
287
+ statusName: ticketData.statusName,
288
+ priority: ticketData.priority,
289
+ category: ticketData.category,
290
+ description: ticketData.description,
291
+ epicId: ticketData.epicId,
292
+ labels: ticketData.labels,
293
+ });
294
+ if (!createResult.success || !createResult.ticket) {
295
+ return handleError('CREATE_FAILED', `Failed to create ticket: ${createResult.error}`);
296
+ }
297
+ const ticket = createResult.ticket;
298
+ // Add subtasks from template if applicable
299
+ if (template && template.suggestedSubtasks.length > 0) {
300
+ // Sequential subtask creation for consistent ordering
301
+ for (const subtask of template.suggestedSubtasks) {
302
+ // eslint-disable-next-line no-await-in-loop
303
+ await this.storage.addSubtask(ticket.id, subtask.title);
304
+ }
305
+ }
306
+ // Auto-export to board.md after write
307
+ await autoExportToBoard(this.pmoPath, this.storage, (msg) => this.log(styles.muted(msg)));
308
+ // If linked to an epic, update the epic's markdown file with ticket list
309
+ if (ticketData.epicId) {
310
+ const epic = await this.storage.getEpic(ticketData.epicId);
311
+ if (epic) {
312
+ const epicTickets = await this.storage.getTicketsForEpic(projectId, ticketData.epicId);
313
+ const ticketInfos = epicTickets.map(t => ({
314
+ id: t.id,
315
+ title: t.title,
316
+ status: t.statusName || 'Unknown',
317
+ priority: t.priority,
318
+ }));
319
+ updateEpicTicketsSection(this.pmoPath, ticketData.epicId, epic.status, ticketInfos, projectId);
320
+ }
321
+ }
322
+ // JSON output mode - match MCP tool response shape
323
+ if (jsonMode) {
324
+ this.log(JSON.stringify({
325
+ success: true,
326
+ ticket: {
327
+ id: ticket.id,
328
+ title: ticket.title,
329
+ priority: ticket.priority,
330
+ category: ticket.category,
331
+ statusName: ticket.statusName,
332
+ statusCategory: ticket.statusCategory,
333
+ projectId: ticket.projectId,
334
+ assignee: ticket.assignee,
335
+ owner: ticket.owner,
336
+ branch: ticket.branch,
337
+ epicId: ticket.epicId,
338
+ position: ticket.position,
339
+ },
340
+ }, null, 2));
341
+ return;
342
+ }
343
+ this.log(styles.success(`\n✅ Created ticket ${styles.emphasis(ticket.id)} in project ${styles.emphasis(projectName)}`));
344
+ if (template) {
345
+ this.log(styles.muted(` Template: ${template.name}`));
346
+ }
347
+ this.log(styles.muted(` Title: ${ticket.title}`));
348
+ this.log(styles.muted(` Status: ${ticket.statusName}`));
349
+ if (ticket.priority) {
350
+ this.log(styles.muted(` Priority: ${ticket.priority}`));
351
+ }
352
+ if (ticket.category) {
353
+ this.log(styles.muted(` Category: ${ticket.category}`));
354
+ }
355
+ if (ticketData.epicId) {
356
+ this.log(styles.muted(` Epic: ${ticketData.epicId}`));
357
+ }
358
+ if (ticketData.labels && ticketData.labels.length > 0) {
359
+ this.log(styles.muted(` Labels: ${ticketData.labels.join(', ')}`));
360
+ }
361
+ if (template && template.suggestedSubtasks.length > 0) {
362
+ this.log(styles.muted(` Subtasks: ${template.suggestedSubtasks.length} created`));
363
+ }
364
+ this.log(styles.muted(`\n View board: prlt board`));
365
+ this.log(styles.muted(` List tickets: prlt ticket list`));
366
+ }
367
+ /**
368
+ * Determine whether to route through Linear or PMO based on --source flag
369
+ * and workspace config.
370
+ *
371
+ * Resolution order for auto mode:
372
+ * 1. Explicit default work source (loadDefaultWorkSource) — user preference
373
+ * 2. Single external provider configured — auto-select it
374
+ * 3. Multiple external providers — prompt user to choose
375
+ * 4. No external providers — fall back to PMO
376
+ */
377
+ async resolveSource(flags, jsonMode) {
378
+ const source = flags.source || 'auto';
379
+ if (source === 'pmo')
380
+ return 'pmo';
381
+ if (source === 'linear')
382
+ return 'linear';
383
+ // auto: resolve from workspace config
384
+ const db = this.storage.getDatabase();
385
+ try {
386
+ // 1. Respect explicit default work source if configured
387
+ const defaultSource = loadDefaultWorkSource(db);
388
+ if (defaultSource?.provider === 'linear')
389
+ return 'linear';
390
+ if (defaultSource?.provider === 'pmo')
391
+ return 'pmo';
392
+ // 2. Check registered providers (PMO is always implicitly registered)
393
+ const registeredSources = getRegisteredWorkSources(db);
394
+ const externalProviders = [...new Set(registeredSources.map(s => s.provider).filter(p => p !== 'pmo'))];
395
+ // No external providers — use PMO
396
+ if (externalProviders.length === 0)
397
+ return 'pmo';
398
+ // Single external provider — auto-select it
399
+ if (externalProviders.length === 1) {
400
+ return externalProviders[0] === 'linear' ? 'linear' : 'pmo';
401
+ }
402
+ // Multiple external providers — prompt user to select
403
+ const allProviders = ['pmo', ...externalProviders];
404
+ const choices = allProviders.map(p => ({
405
+ name: p === 'pmo' ? 'PMO (local)' : `${p.charAt(0).toUpperCase() + p.slice(1)}`,
406
+ value: p,
407
+ }));
408
+ const message = 'Multiple ticket providers configured. Where should this ticket be created?';
409
+ const selectedSource = await this.selectFromList({
410
+ message,
411
+ items: allProviders.map(p => ({
412
+ name: p === 'pmo' ? 'PMO (local)' : `${p.charAt(0).toUpperCase() + p.slice(1)}`,
413
+ value: p,
414
+ })),
415
+ getName: (item) => item.name,
416
+ getValue: (item) => item.value,
417
+ getCommand: (item) => `prlt ticket create --source ${item.value} --json`,
418
+ jsonMode: jsonMode
419
+ ? { flags: flags, commandName: 'ticket create' }
420
+ : null,
421
+ });
422
+ // In JSON mode selectFromList returns null (prompt already emitted)
423
+ if (selectedSource === null)
424
+ return 'pmo';
425
+ // Only 'linear' gets the special path; everything else falls through to PMO
426
+ return selectedSource === 'linear' ? 'linear' : 'pmo';
427
+ }
428
+ catch {
429
+ // workspace_settings table may not exist in older/test databases
430
+ return 'pmo';
431
+ }
432
+ }
433
+ /**
434
+ * Create a ticket through the Linear provider adapter.
435
+ * Collects inputs (title, description, priority, labels) and routes
436
+ * through the provider, which handles the Linear API and mirror creation.
437
+ */
438
+ async createLinearIssue(flags, jsonMode) {
439
+ const handleError = (code, message) => {
440
+ if (jsonMode) {
441
+ outputErrorAsJson(code, message, createMetadata('ticket create', flags));
442
+ return;
443
+ }
444
+ this.error(message);
445
+ };
446
+ // Collect title (required)
447
+ let title = flags.title;
448
+ if (!title) {
449
+ const inputTitle = await this.promptForInput({
450
+ message: 'Enter ticket title:',
451
+ fieldName: 'title',
452
+ validate: (input) => input.trim() ? true : 'Title cannot be empty',
453
+ jsonMode: jsonMode
454
+ ? { flags: flags, commandName: 'ticket create', commandHint: 'Provide --title flag', example: 'prlt ticket create --source linear --title "My ticket"' }
455
+ : null,
456
+ });
457
+ // In JSON mode, promptForInput returns '' (prompt already emitted)
458
+ if (jsonMode && !inputTitle)
459
+ return;
460
+ title = inputTitle;
461
+ }
462
+ // NOTE: --description-file handling is done early in execute(), before routing.
463
+ // flags.description is already populated if --description-file was provided.
464
+ const description = flags.description;
465
+ const pmoPriority = flags.priority;
466
+ const category = flags.category;
467
+ // Parse labels from flag
468
+ const labelsInput = flags.labels;
469
+ const labelNames = labelsInput
470
+ ? labelsInput.split(',').map(l => l.trim()).filter(Boolean)
471
+ : [];
472
+ // Handle dry-run
473
+ if (flags['dry-run']) {
474
+ const wouldCreate = {
475
+ source: 'linear',
476
+ title: title,
477
+ ...(description && { description }),
478
+ ...(pmoPriority && { priority: pmoPriority }),
479
+ ...(labelNames.length > 0 && { labels: labelNames }),
480
+ ...(category && { category }),
481
+ };
482
+ if (jsonMode) {
483
+ outputDryRunSuccessAsJson('ticket', wouldCreate, createMetadata('ticket create', flags));
484
+ return;
485
+ }
486
+ this.log(styles.warning('\n[DRY RUN] Would create Linear issue:'));
487
+ this.log(styles.muted(` Title: ${title}`));
488
+ if (pmoPriority) {
489
+ this.log(styles.muted(` Priority: ${pmoPriority}`));
490
+ }
491
+ if (labelNames.length > 0) {
492
+ this.log(styles.muted(` Labels: ${labelNames.join(', ')}`));
493
+ }
494
+ if (category) {
495
+ this.log(styles.muted(` Category: ${category}`));
496
+ }
497
+ if (description) {
498
+ const shortDesc = description.split('\n')[0].substring(0, 60);
499
+ this.log(styles.muted(` Description: ${shortDesc}${description.length > 60 ? '...' : ''}`));
500
+ }
501
+ this.log(styles.muted('\n(No issue was created)'));
502
+ return;
503
+ }
504
+ // Get project for the provider (needed for PMO mirror)
505
+ const projectId = await this.requireProject({
506
+ jsonMode: {
507
+ flags: flags,
508
+ commandName: 'ticket create',
509
+ baseCommand: 'prlt ticket create --source linear',
510
+ },
511
+ });
512
+ // Route through provider adapter — Linear provider handles API call, mirror, and mapping
513
+ const provider = this.resolveProjectProvider(projectId, 'linear');
514
+ const teamKey = flags.team;
515
+ const createResult = await provider.createTicket(projectId, {
516
+ title: title,
517
+ description,
518
+ priority: pmoPriority,
519
+ category,
520
+ labels: labelNames.length > 0 ? labelNames : undefined,
521
+ metadata: teamKey ? { 'linear.team': teamKey } : undefined,
522
+ });
523
+ if (!createResult.success || !createResult.ticket) {
524
+ return handleError('CREATE_FAILED', `Failed to create ticket: ${createResult.error}`);
525
+ }
526
+ const ticket = createResult.ticket;
527
+ const externalKey = ticket.metadata?.external_key;
528
+ const externalUrl = ticket.metadata?.external_url;
529
+ // JSON output
530
+ if (jsonMode) {
531
+ this.log(JSON.stringify({
532
+ success: true,
533
+ source: 'linear',
534
+ ticket: {
535
+ id: ticket.id,
536
+ title: ticket.title,
537
+ priority: ticket.priority,
538
+ category: ticket.category,
539
+ statusName: ticket.statusName,
540
+ projectId: ticket.projectId,
541
+ ...(externalKey && { externalKey }),
542
+ ...(externalUrl && { externalUrl }),
543
+ },
544
+ }, null, 2));
545
+ return;
546
+ }
547
+ this.log(styles.success(`\n✅ Created ticket ${styles.emphasis(externalKey || ticket.id)} via Linear`));
548
+ this.log(styles.muted(` Title: ${ticket.title}`));
549
+ if (ticket.priority) {
550
+ this.log(styles.muted(` Priority: ${ticket.priority}`));
551
+ }
552
+ if (externalUrl) {
553
+ this.log(styles.muted(` URL: ${externalUrl}`));
554
+ }
555
+ }
556
+ async promptTicketData(flags, storage, existingTemplate, columns) {
557
+ // If no template was specified via flag, offer to select one
558
+ let template = existingTemplate;
559
+ if (!template && !flags.template) {
560
+ const templates = await storage.listTicketTemplates();
561
+ if (templates.length > 0) {
562
+ const { selectedTemplate } = await this.prompt([
563
+ {
564
+ type: 'list',
565
+ name: 'selectedTemplate',
566
+ message: 'Start from a template?',
567
+ choices: [
568
+ { name: 'No template (blank ticket)', value: '' },
569
+ new inquirer.Separator('── Templates ──'),
570
+ ...templates.map(t => ({
571
+ name: `${t.name}${t.isBuiltin ? '' : ' [custom]'} - ${t.description || ''}`,
572
+ value: t.id,
573
+ })),
574
+ ],
575
+ },
576
+ ], null);
577
+ if (selectedTemplate) {
578
+ template = templates.find(t => t.id === selectedTemplate) || null;
579
+ }
580
+ }
581
+ }
582
+ // Prompt for title
583
+ const { title: answerTitle } = await this.prompt([
584
+ {
585
+ type: 'input',
586
+ name: 'title',
587
+ message: 'Ticket title:',
588
+ default: flags.title || template?.titlePattern,
589
+ validate: (input) => input.trim() ? true : 'Title cannot be empty',
590
+ },
591
+ ], null);
592
+ // Prompt for column
593
+ const { column: answerColumn } = await this.prompt([
594
+ {
595
+ type: 'list',
596
+ name: 'column',
597
+ message: 'Column:',
598
+ choices: columns.map(c => ({ name: c, value: c })),
599
+ default: flags.column || columns[0],
600
+ },
601
+ ], null);
602
+ // Prompt for priority (using workspace priority scale)
603
+ const db = this.storage.getDatabase();
604
+ const workspacePriorities = getWorkspacePriorities(db);
605
+ const { priority: answerPriority } = await this.prompt([
606
+ {
607
+ type: 'list',
608
+ name: 'priority',
609
+ message: 'Priority:',
610
+ choices: [
611
+ { name: 'None', value: undefined },
612
+ ...workspacePriorities.map(p => ({ name: p, value: p })),
613
+ ],
614
+ default: flags.priority || template?.defaultPriority,
615
+ },
616
+ ], null);
617
+ // Prompt for category
618
+ const { categoryChoice } = await this.prompt([
619
+ {
620
+ type: 'list',
621
+ name: 'categoryChoice',
622
+ message: 'Category:',
623
+ choices: [
624
+ { name: 'Skip (none)', value: '' },
625
+ new inquirer.Separator('── Conventional Commits ──'),
626
+ { name: 'feature - New feature or capability', value: 'feature' },
627
+ { name: 'bug - Bug fix', value: 'bug' },
628
+ { name: 'refactor - Code refactoring', value: 'refactor' },
629
+ { name: 'docs - Documentation', value: 'docs' },
630
+ { name: 'test - Test additions/fixes', value: 'test' },
631
+ { name: 'chore - Maintenance tasks', value: 'chore' },
632
+ { name: 'performance - Performance improvements', value: 'performance' },
633
+ { name: 'ci - CI/CD changes', value: 'ci' },
634
+ { name: 'build - Build system changes', value: 'build' },
635
+ new inquirer.Separator('── Extended Types ──'),
636
+ { name: 'security - Security fixes', value: 'security' },
637
+ { name: 'database - Database migrations', value: 'database' },
638
+ { name: 'release - Release preparation', value: 'release' },
639
+ new inquirer.Separator('── 5Tool Founder ──'),
640
+ { name: 'ship - Shipping and deployment', value: 'ship' },
641
+ { name: 'growth - Growth and marketing', value: 'growth' },
642
+ { name: 'support - Customer experience', value: 'support' },
643
+ { name: 'strategy - Strategy and planning', value: 'strategy' },
644
+ { name: 'ops - Business operations', value: 'ops' },
645
+ new inquirer.Separator('───────────────────'),
646
+ { name: 'Custom...', value: '__custom__' },
647
+ ],
648
+ default: flags.category || template?.defaultCategory || '',
649
+ },
650
+ ], null);
651
+ // Custom category prompt if needed
652
+ let customCategory;
653
+ if (categoryChoice === '__custom__') {
654
+ const result = await this.prompt([{
655
+ type: 'input',
656
+ name: 'customCategory',
657
+ message: 'Enter custom category:',
658
+ validate: (input) => input.trim() ? true : 'Category cannot be empty',
659
+ }], null);
660
+ customCategory = result.customCategory;
661
+ }
662
+ const answers = { title: answerTitle, column: answerColumn, priority: answerPriority, categoryChoice, customCategory };
663
+ // Resolve category from choice or custom input
664
+ const category = answers.categoryChoice === '__custom__'
665
+ ? answers.customCategory
666
+ : answers.categoryChoice || undefined;
667
+ // Prompt for structured description (use template description if available)
668
+ const description = await this.promptStructuredDescription(flags.description || template?.descriptionTemplate);
669
+ // Parse labels from flag or use template defaults
670
+ const labels = flags.labels
671
+ ? flags.labels.split(',').map(l => l.trim()).filter(Boolean)
672
+ : template?.defaultLabels;
673
+ return {
674
+ title: answers.title,
675
+ statusName: answers.column,
676
+ priority: answers.priority || undefined,
677
+ category,
678
+ description: description || undefined,
679
+ id: flags.id,
680
+ epicId: flags.epic,
681
+ labels,
682
+ };
683
+ }
684
+ async promptStructuredDescription(existingDescription) {
685
+ // If description already provided via flag, use it
686
+ if (existingDescription) {
687
+ return existingDescription;
688
+ }
689
+ this.log(styles.muted('\n─── Ticket Description (for agent execution) ───'));
690
+ // Prompt for "What" - the main outcome
691
+ const { what } = await this.prompt([
692
+ {
693
+ type: 'input',
694
+ name: 'what',
695
+ message: 'What is the concrete outcome? (one sentence):',
696
+ validate: (input) => input.trim() ? true : 'Outcome cannot be empty - what does success look like?',
697
+ },
698
+ ], null);
699
+ // Prompt for acceptance criteria using multiline input
700
+ const doneWhenResult = await multiLineInput({
701
+ message: 'Done when (acceptance criteria):',
702
+ hint: 'Enter each criterion on a new line. Ctrl+D to finish, Ctrl+C to cancel',
703
+ });
704
+ if (doneWhenResult.cancelled) {
705
+ throw new Error('Ticket creation cancelled');
706
+ }
707
+ // Continue with remaining prompts
708
+ const { context } = await this.prompt([
709
+ {
710
+ type: 'input',
711
+ name: 'context',
712
+ message: 'Context (files, patterns, hints - optional):',
713
+ default: '',
714
+ },
715
+ ], null);
716
+ const { notInScope } = await this.prompt([
717
+ {
718
+ type: 'input',
719
+ name: 'notInScope',
720
+ message: 'Not in scope (explicit exclusions - optional):',
721
+ default: '',
722
+ },
723
+ ], null);
724
+ // Build structured description
725
+ const parts = [];
726
+ parts.push(`## What\n${what}`);
727
+ if (doneWhenResult.value.trim()) {
728
+ // Ensure each line in doneWhen starts with - [ ] if it doesn't already
729
+ const criteria = doneWhenResult.value
730
+ .split('\n')
731
+ .map(line => line.trim())
732
+ .filter(line => line.length > 0)
733
+ .map(line => {
734
+ if (line.startsWith('- [ ]') || line.startsWith('- [x]')) {
735
+ return line;
736
+ }
737
+ if (line.startsWith('-')) {
738
+ return `- [ ]${line.slice(1)}`;
739
+ }
740
+ return `- [ ] ${line}`;
741
+ })
742
+ .join('\n');
743
+ parts.push(`## Done when\n${criteria}`);
744
+ }
745
+ if (context.trim()) {
746
+ parts.push(`## Context\n${context}`);
747
+ }
748
+ if (notInScope.trim()) {
749
+ parts.push(`## Not in scope\n${notInScope}`);
750
+ }
751
+ return parts.join('\n\n');
752
+ }
753
+ }
754
+ //# sourceMappingURL=create.js.map