@proletariat/cli 0.3.29 → 0.3.31

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.
@@ -8,7 +8,7 @@ import { shouldOutputJson, outputErrorAsJson, createMetadata, outputConfirmation
8
8
  import { FlagResolver } from '../../lib/flags/index.js';
9
9
  import { getWorkColumnSetting, findColumnByName } from '../../lib/pmo/utils.js';
10
10
  import { styles } from '../../lib/styles.js';
11
- import { getWorkspaceInfo, createEphemeralAgent, getTicketTmuxSession, killTmuxSession, } from '../../lib/agents/commands.js';
11
+ import { getWorkspaceInfo, createEphemeralAgent, getTicketTmuxSession, killTmuxSession, findWorktreeForBranch, } from '../../lib/agents/commands.js';
12
12
  import { generateBranchName, DEFAULT_EXECUTION_CONFIG, } from '../../lib/execution/types.js';
13
13
  import { runExecution, isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled, dockerCredentialsExist, getDockerCredentialInfo } from '../../lib/execution/runners.js';
14
14
  import { ExecutionStorage, ContainerStorage } from '../../lib/execution/storage.js';
@@ -397,11 +397,33 @@ export default class WorkStart extends PMOCommand {
397
397
  }
398
398
  // For 'spawn', we continue with creating a new agent
399
399
  }
400
+ // Check for existing worktree with ticket's branch (dead agent recovery)
401
+ let reusingWorktree = false;
402
+ if (ticket.branch && !flags.agent) {
403
+ const existingWorktree = findWorktreeForBranch(workspaceInfo, ticket.branch);
404
+ if (existingWorktree) {
405
+ reusingWorktree = true;
406
+ if (!jsonMode) {
407
+ this.log(styles.muted(`Found existing worktree for branch in dead agent "${existingWorktree.agentName}"`));
408
+ this.log(styles.muted(`Reusing worktree at: ${existingWorktree.agentDir}`));
409
+ }
410
+ }
411
+ }
400
412
  // Agent selection: ephemeral flag, agent flag, ticket assignee, or prompt
401
413
  let agentName;
402
414
  let agentWorktreePath;
403
415
  let isEphemeralAgent = flags.ephemeral;
404
- if (flags.ephemeral) {
416
+ if (reusingWorktree) {
417
+ // Reuse dead agent's worktree — skip creating a new agent
418
+ const existingWorktree = findWorktreeForBranch(workspaceInfo, ticket.branch);
419
+ agentName = existingWorktree.agentName;
420
+ agentWorktreePath = existingWorktree.agentDir;
421
+ isEphemeralAgent = true;
422
+ if (!jsonMode) {
423
+ this.log(styles.success(`Reusing agent: ${agentName} (worktree recovery)`));
424
+ }
425
+ }
426
+ else if (flags.ephemeral) {
405
427
  // Create ephemeral agent on-demand
406
428
  if (!jsonMode) {
407
429
  this.log(styles.muted('Creating ephemeral agent...'));
@@ -1301,8 +1323,12 @@ export default class WorkStart extends PMOCommand {
1301
1323
  }
1302
1324
  // Note: fetch already happened above (unconditionally for all action types)
1303
1325
  try {
1304
- // Check if branch exists and checkout
1305
- if (tryGitCommand(`git rev-parse --verify ${finalBranch}`, repoPath)) {
1326
+ if (reusingWorktree) {
1327
+ // Branch already checked out in this worktree — just fetch latest
1328
+ this.log(styles.muted(` ${repoName}: reusing existing branch (worktree recovery)`));
1329
+ }
1330
+ else if (tryGitCommand(`git rev-parse --verify ${finalBranch}`, repoPath)) {
1331
+ // Check if branch exists and checkout
1306
1332
  execSync(`git checkout ${finalBranch}`, { cwd: repoPath, stdio: 'pipe' });
1307
1333
  this.log(styles.muted(` ${repoName}: checked out branch`));
1308
1334
  }
@@ -177,6 +177,20 @@ export interface CleanupResult {
177
177
  * Get tmux sessions associated with an agent
178
178
  */
179
179
  export declare function getAgentTmuxSessions(agentName: string): string[];
180
+ export interface ExistingWorktreeInfo {
181
+ agentName: string;
182
+ agentDir: string;
183
+ worktreePath: string;
184
+ branch: string;
185
+ }
186
+ /**
187
+ * Find an existing worktree that has a specific branch checked out.
188
+ * Used to detect dead agents whose worktrees still hold a branch,
189
+ * allowing worktree reuse instead of creating a fresh agent.
190
+ *
191
+ * Returns null if no worktree has the branch, or if the agent is still alive.
192
+ */
193
+ export declare function findWorktreeForBranch(workspaceInfo: WorkspaceInfo, branch: string): ExistingWorktreeInfo | null;
180
194
  /**
181
195
  * Check git status for all worktrees in an agent directory.
182
196
  * Returns info about uncommitted changes and unpushed commits.
@@ -604,6 +604,64 @@ export function getAgentTmuxSessions(agentName) {
604
604
  .filter(s => s.agent === agentName)
605
605
  .map(s => s.name);
606
606
  }
607
+ /**
608
+ * Find an existing worktree that has a specific branch checked out.
609
+ * Used to detect dead agents whose worktrees still hold a branch,
610
+ * allowing worktree reuse instead of creating a fresh agent.
611
+ *
612
+ * Returns null if no worktree has the branch, or if the agent is still alive.
613
+ */
614
+ export function findWorktreeForBranch(workspaceInfo, branch) {
615
+ for (const repo of workspaceInfo.repositories) {
616
+ const sourceRepoPath = path.join(workspaceInfo.path, 'repos', repo.name);
617
+ if (!fs.existsSync(sourceRepoPath))
618
+ continue;
619
+ try {
620
+ const output = execSync('git worktree list --porcelain', {
621
+ cwd: sourceRepoPath,
622
+ encoding: 'utf-8',
623
+ stdio: ['pipe', 'pipe', 'pipe'],
624
+ });
625
+ const blocks = output.split('\n\n').filter(Boolean);
626
+ for (const block of blocks) {
627
+ const lines = block.split('\n');
628
+ const worktreeLine = lines.find(l => l.startsWith('worktree '));
629
+ const branchLine = lines.find(l => l.startsWith('branch '));
630
+ const isPrunable = lines.some(l => l.startsWith('prunable'));
631
+ if (!worktreeLine || !branchLine || isPrunable)
632
+ continue;
633
+ const worktreeFullPath = worktreeLine.replace('worktree ', '');
634
+ const branchRef = branchLine.replace('branch refs/heads/', '');
635
+ if (branchRef !== branch)
636
+ continue;
637
+ // Found a worktree with this branch — confirm it's inside agents/
638
+ const relativePath = path.relative(workspaceInfo.path, worktreeFullPath);
639
+ if (!relativePath.startsWith('agents/'))
640
+ continue;
641
+ // Extract agent name: agents/{subdir}/{agentName}/{repoName}
642
+ const parts = relativePath.split(path.sep);
643
+ if (parts.length < 4)
644
+ continue;
645
+ const agentName = parts[parts.length - 2];
646
+ const agentDir = path.dirname(worktreeFullPath);
647
+ // Confirm agent has no active tmux sessions (it's dead)
648
+ const agentSessions = getAgentTmuxSessions(agentName);
649
+ if (agentSessions.length > 0)
650
+ continue;
651
+ return {
652
+ agentName,
653
+ agentDir,
654
+ worktreePath: worktreeFullPath,
655
+ branch: branchRef,
656
+ };
657
+ }
658
+ }
659
+ catch {
660
+ continue;
661
+ }
662
+ }
663
+ return null;
664
+ }
607
665
  /**
608
666
  * Get docker containers associated with an agent directory
609
667
  */
@@ -2503,6 +2503,23 @@ export declare const pmoTickets: import("drizzle-orm/sqlite-core").SQLiteTableWi
2503
2503
  identity: undefined;
2504
2504
  generated: undefined;
2505
2505
  }, object>;
2506
+ position: import("drizzle-orm/sqlite-core").SQLiteColumn<{
2507
+ name: "position";
2508
+ tableName: "pmo_tickets";
2509
+ dataType: "number";
2510
+ columnType: "SQLiteInteger";
2511
+ data: number;
2512
+ driverParam: number;
2513
+ notNull: true;
2514
+ hasDefault: true;
2515
+ isPrimaryKey: false;
2516
+ isAutoincrement: false;
2517
+ hasRuntimeDefault: false;
2518
+ enumValues: undefined;
2519
+ baseColumn: never;
2520
+ identity: undefined;
2521
+ generated: undefined;
2522
+ }, object>;
2506
2523
  createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
2507
2524
  name: "created_at";
2508
2525
  tableName: "pmo_tickets";
@@ -284,6 +284,7 @@ export const pmoTickets = sqliteTable('pmo_tickets', {
284
284
  specId: text('spec_id'),
285
285
  epicId: text('epic_id'),
286
286
  labels: text('labels').notNull().default('[]'),
287
+ position: integer('position').notNull().default(0),
287
288
  createdAt: text('created_at').default(sql `CURRENT_TIMESTAMP`),
288
289
  updatedAt: text('updated_at').default(sql `CURRENT_TIMESTAMP`),
289
290
  lastSyncedFromSpec: text('last_synced_from_spec'),
@@ -298,6 +299,7 @@ export const pmoTickets = sqliteTable('pmo_tickets', {
298
299
  idxPriority: index('idx_pmo_tickets_priority').on(table.priority),
299
300
  idxCategory: index('idx_pmo_tickets_category').on(table.category),
300
301
  idxStatusId: index('idx_pmo_tickets_status_id').on(table.statusId),
302
+ idxStatusPosition: index('idx_pmo_tickets_status_position').on(table.statusId, table.position),
301
303
  }));
302
304
  /**
303
305
  * Board views (saved filter/display configurations)
@@ -195,6 +195,45 @@ export function openWorkspaceDatabase(workspacePath) {
195
195
  catch {
196
196
  // Ignore migration errors - table might not exist yet or column already exists
197
197
  }
198
+ // Migration: add position column to pmo_tickets table (TKT-965)
199
+ try {
200
+ const ticketsTableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pmo_tickets'").get();
201
+ if (ticketsTableExists) {
202
+ const ticketsTableInfo = db.prepare("PRAGMA table_info(pmo_tickets)").all();
203
+ if (!ticketsTableInfo.some(col => col.name === 'position')) {
204
+ db.exec("ALTER TABLE pmo_tickets ADD COLUMN position INTEGER NOT NULL DEFAULT 0");
205
+ db.exec("CREATE INDEX IF NOT EXISTS idx_pmo_tickets_status_position ON pmo_tickets(status_id, position)");
206
+ // Backfill existing tickets with gapped positions (1000, 2000, ...) per status,
207
+ // ordered by priority then created_at
208
+ const statuses = db.prepare("SELECT DISTINCT status_id FROM pmo_tickets WHERE status_id IS NOT NULL").all();
209
+ const getTicketsForStatus = db.prepare(`
210
+ SELECT id FROM pmo_tickets WHERE status_id = ?
211
+ ORDER BY
212
+ CASE priority
213
+ WHEN 'P0' THEN 0
214
+ WHEN 'P1' THEN 1
215
+ WHEN 'P2' THEN 2
216
+ WHEN 'P3' THEN 3
217
+ ELSE 4
218
+ END,
219
+ created_at ASC
220
+ `);
221
+ const updatePosition = db.prepare("UPDATE pmo_tickets SET position = ? WHERE id = ?");
222
+ const backfill = db.transaction(() => {
223
+ for (const { status_id } of statuses) {
224
+ const tickets = getTicketsForStatus.all(status_id);
225
+ tickets.forEach((ticket, idx) => {
226
+ updatePosition.run((idx + 1) * 1000, ticket.id);
227
+ });
228
+ }
229
+ });
230
+ backfill();
231
+ }
232
+ }
233
+ }
234
+ catch {
235
+ // Ignore migration errors - table might not exist yet
236
+ }
198
237
  return db;
199
238
  }
200
239
  /**
@@ -865,10 +865,10 @@ function runContainerSetup(containerId, sandboxed = true) {
865
865
  }
866
866
  const tips = settings.tipsHistory;
867
867
  tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
868
- // Base64 encode to avoid shell escaping issues
869
- const base64Content = Buffer.from(JSON.stringify(settings)).toString('base64');
870
- // Write to container at /home/node/.claude.json
871
- execSync(`docker exec ${containerId} bash -c 'echo "${base64Content}" | base64 -d > /home/node/.claude.json'`, { stdio: 'pipe' });
868
+ // Pipe settings via stdin to avoid ARG_MAX limits with large .claude.json files
869
+ const settingsJson = JSON.stringify(settings);
870
+ // Write to container at /home/node/.claude.json using stdin piping
871
+ execSync(`docker exec -i ${containerId} bash -c 'cat > /home/node/.claude.json'`, { input: settingsJson, stdio: ['pipe', 'pipe', 'pipe'] });
872
872
  console.debug(`[runners:docker] Copied .claude.json settings to container (bypassPermissionsModeAccepted=${!sandboxed})`);
873
873
  }
874
874
  catch (error) {
@@ -1459,7 +1459,6 @@ echo ""
1459
1459
  echo "✅ Agent work complete. Press Enter to close or run more commands."
1460
1460
  exec bash
1461
1461
  `;
1462
- const base64Script = Buffer.from(tmuxScript).toString('base64');
1463
1462
  const scriptPath = `/tmp/prlt-${sessionName}.sh`;
1464
1463
  // Write script and start tmux session inside container
1465
1464
  // IMPORTANT: We create the session with bash first, then send keys to run the script.
@@ -1470,10 +1469,12 @@ exec bash
1470
1469
  // Only enable mouse mode if NOT using control mode (control mode lets iTerm handle mouse natively)
1471
1470
  // set-titles on + set-titles-string: makes tmux set terminal title to window name
1472
1471
  const mouseOption = buildTmuxMouseOption(useControlMode);
1473
- // Step 1: Write the script to the container
1474
- const writeScriptCmd = `echo ${base64Script} | base64 -d > ${scriptPath} && chmod +x ${scriptPath}`;
1472
+ // Step 1: Write the script to the container via stdin piping to avoid ARG_MAX limits
1475
1473
  try {
1476
- execSync(`docker exec ${actualContainerId} bash -c '${writeScriptCmd}'`, { stdio: 'pipe' });
1474
+ execSync(`docker exec -i ${actualContainerId} bash -c 'cat > ${scriptPath} && chmod +x ${scriptPath}'`, {
1475
+ input: tmuxScript,
1476
+ stdio: ['pipe', 'pipe', 'pipe'],
1477
+ });
1477
1478
  }
1478
1479
  catch (error) {
1479
1480
  return {
@@ -30,6 +30,7 @@ export declare function formatTicket(t: Ticket): {
30
30
  owner: string | undefined;
31
31
  branch: string | undefined;
32
32
  epicId: string | undefined;
33
+ position: number | undefined;
33
34
  };
34
35
  export declare function formatTicketFull(t: Ticket): {
35
36
  description: string | undefined;
@@ -52,6 +53,7 @@ export declare function formatTicketFull(t: Ticket): {
52
53
  owner: string | undefined;
53
54
  branch: string | undefined;
54
55
  epicId: string | undefined;
56
+ position: number | undefined;
55
57
  };
56
58
  export declare function successResponse(data: Record<string, unknown>): McpToolResult;
57
59
  export declare function errorResponse(error: unknown): McpToolResult;
@@ -29,6 +29,7 @@ export function formatTicket(t) {
29
29
  owner: t.owner,
30
30
  branch: t.branch,
31
31
  epicId: t.epicId,
32
+ position: t.position,
32
33
  };
33
34
  }
34
35
  export function formatTicketFull(t) {
@@ -45,6 +45,7 @@ export function registerTicketTools(server, ctx) {
45
45
  owner: t.owner,
46
46
  epicId: t.epicId,
47
47
  branch: t.branch,
48
+ position: t.position,
48
49
  createdAt: t.createdAt.toISOString(),
49
50
  updatedAt: t.updatedAt.toISOString(),
50
51
  })),
@@ -206,6 +207,30 @@ export function registerTicketTools(server, ctx) {
206
207
  return errorResponse(error);
207
208
  }
208
209
  });
210
+ strictTool(server, 'ticket_reorder', 'Reorder a ticket within its current status. Provide either a direct position value or place it after another ticket.', {
211
+ id: z.string().describe('Ticket ID to reorder'),
212
+ position: z.number().optional().describe('Direct position value (gapped integers, e.g. 1000, 2000)'),
213
+ after_ticket_id: z.string().optional().describe('Place this ticket after the specified ticket ID'),
214
+ }, async (params) => {
215
+ try {
216
+ if (!params.position && !params.after_ticket_id) {
217
+ throw new Error('Must provide either position or after_ticket_id');
218
+ }
219
+ const ticket = await ctx.storage.reorderTicket(params.id, {
220
+ position: params.position,
221
+ afterTicketId: params.after_ticket_id,
222
+ });
223
+ return {
224
+ content: [{
225
+ type: 'text',
226
+ text: JSON.stringify({ success: true, ticket: formatTicket(ticket) }, null, 2),
227
+ }],
228
+ };
229
+ }
230
+ catch (error) {
231
+ return errorResponse(error);
232
+ }
233
+ });
209
234
  strictTool(server, 'ticket_add_subtask', 'Add a subtask to a ticket', {
210
235
  ticket_id: z.string().describe('Ticket ID'),
211
236
  title: z.string().describe('Subtask title'),
@@ -46,7 +46,7 @@ export declare const PMO_TABLE_SCHEMAS: {
46
46
  readonly workflows: "\n CREATE TABLE IF NOT EXISTS pmo_workflows (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n description TEXT,\n is_builtin INTEGER NOT NULL DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n )";
47
47
  readonly workflow_statuses: "\n CREATE TABLE IF NOT EXISTS pmo_workflow_statuses (\n id TEXT PRIMARY KEY,\n workflow_id TEXT NOT NULL,\n name TEXT NOT NULL,\n category TEXT NOT NULL,\n position INTEGER NOT NULL DEFAULT 0,\n color TEXT,\n description TEXT,\n is_default INTEGER NOT NULL DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (workflow_id) REFERENCES pmo_workflows(id) ON DELETE CASCADE,\n UNIQUE(workflow_id, name)\n )";
48
48
  readonly columns: "\n CREATE TABLE IF NOT EXISTS pmo_columns (\n id TEXT NOT NULL,\n project_id TEXT NOT NULL DEFAULT 'default',\n name TEXT NOT NULL,\n position INTEGER NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (project_id, id)\n )";
49
- readonly tickets: "\n CREATE TABLE IF NOT EXISTS pmo_tickets (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL DEFAULT 'default',\n title TEXT NOT NULL,\n description TEXT,\n priority TEXT,\n category TEXT,\n status TEXT NOT NULL DEFAULT 'backlog',\n status_id TEXT,\n owner TEXT,\n assignee TEXT,\n branch TEXT,\n spec_id TEXT,\n epic_id TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_synced_from_spec TIMESTAMP,\n last_synced_from_board TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n FOREIGN KEY (spec_id) REFERENCES pmo_specs(id) ON DELETE SET NULL,\n FOREIGN KEY (epic_id) REFERENCES pmo_epics(id) ON DELETE SET NULL,\n FOREIGN KEY (status_id) REFERENCES pmo_workflow_statuses(id) ON DELETE SET NULL\n )";
49
+ readonly tickets: "\n CREATE TABLE IF NOT EXISTS pmo_tickets (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL DEFAULT 'default',\n title TEXT NOT NULL,\n description TEXT,\n priority TEXT,\n category TEXT,\n status TEXT NOT NULL DEFAULT 'backlog',\n status_id TEXT,\n owner TEXT,\n assignee TEXT,\n branch TEXT,\n spec_id TEXT,\n epic_id TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n position INTEGER NOT NULL DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_synced_from_spec TIMESTAMP,\n last_synced_from_board TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n FOREIGN KEY (spec_id) REFERENCES pmo_specs(id) ON DELETE SET NULL,\n FOREIGN KEY (epic_id) REFERENCES pmo_epics(id) ON DELETE SET NULL,\n FOREIGN KEY (status_id) REFERENCES pmo_workflow_statuses(id) ON DELETE SET NULL\n )";
50
50
  readonly board_tickets: "\n CREATE TABLE IF NOT EXISTS pmo_board_tickets (\n project_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n column_id TEXT NOT NULL,\n position INTEGER NOT NULL,\n PRIMARY KEY (project_id, ticket_id),\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n FOREIGN KEY (ticket_id) REFERENCES pmo_tickets(id) ON DELETE CASCADE,\n FOREIGN KEY (project_id, column_id) REFERENCES pmo_columns(project_id, id) ON DELETE CASCADE\n )";
51
51
  readonly board_views: "\n CREATE TABLE IF NOT EXISTS pmo_board_views (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n is_default INTEGER NOT NULL DEFAULT 0,\n filters TEXT NOT NULL DEFAULT '{}',\n group_by TEXT,\n sort_by TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n UNIQUE(project_id, name)\n )";
52
52
  readonly subtasks: "\n CREATE TABLE IF NOT EXISTS pmo_subtasks (\n id TEXT NOT NULL,\n ticket_id TEXT NOT NULL REFERENCES pmo_tickets(id) ON DELETE CASCADE,\n title TEXT NOT NULL,\n done INTEGER DEFAULT 0,\n position INTEGER NOT NULL,\n PRIMARY KEY (ticket_id, id)\n )";
@@ -80,7 +80,7 @@ export declare const PMO_INDEXES: string;
80
80
  * Order matters due to foreign key dependencies.
81
81
  */
82
82
  export declare const PMO_SCHEMA_SQL: string;
83
- export declare const EXPECTED_TICKET_COLUMNS: readonly ["id", "project_id", "title", "description", "priority", "category", "status", "status_id", "owner", "assignee", "branch", "spec_id", "epic_id", "labels", "created_at", "updated_at", "last_synced_from_spec", "last_synced_from_board"];
83
+ export declare const EXPECTED_TICKET_COLUMNS: readonly ["id", "project_id", "title", "description", "priority", "category", "status", "status_id", "owner", "assignee", "branch", "spec_id", "epic_id", "labels", "position", "created_at", "updated_at", "last_synced_from_spec", "last_synced_from_board"];
84
84
  /**
85
85
  * Validate that pmo_tickets table has all expected columns.
86
86
  * Throws if columns are missing (indicates schema mismatch).
@@ -141,6 +141,7 @@ export const PMO_TABLE_SCHEMAS = {
141
141
  spec_id TEXT,
142
142
  epic_id TEXT,
143
143
  labels TEXT NOT NULL DEFAULT '[]',
144
+ position INTEGER NOT NULL DEFAULT 0,
144
145
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
145
146
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
146
147
  last_synced_from_spec TIMESTAMP,
@@ -459,6 +460,7 @@ export const PMO_INDEXES = `
459
460
  CREATE INDEX IF NOT EXISTS idx_pmo_tickets_priority ON ${PMO_TABLES.tickets}(priority);
460
461
  CREATE INDEX IF NOT EXISTS idx_pmo_tickets_category ON ${PMO_TABLES.tickets}(category);
461
462
  CREATE INDEX IF NOT EXISTS idx_pmo_tickets_status_id ON ${PMO_TABLES.tickets}(status_id);
463
+ CREATE INDEX IF NOT EXISTS idx_pmo_tickets_status_position ON ${PMO_TABLES.tickets}(status_id, position);
462
464
  CREATE INDEX IF NOT EXISTS idx_pmo_subtasks_ticket ON ${PMO_TABLES.subtasks}(ticket_id);
463
465
  CREATE INDEX IF NOT EXISTS idx_pmo_ticket_specs_spec ON ${PMO_TABLES.ticket_specs}(spec_id);
464
466
  CREATE INDEX IF NOT EXISTS idx_pmo_assignments_agent ON ${PMO_TABLES.ticket_assignments}(agent_name);
@@ -564,6 +566,7 @@ export const EXPECTED_TICKET_COLUMNS = [
564
566
  'spec_id',
565
567
  'epic_id',
566
568
  'labels',
569
+ 'position',
567
570
  'created_at',
568
571
  'updated_at',
569
572
  'last_synced_from_spec',
@@ -218,6 +218,37 @@ export function runMigrations(db) {
218
218
  // Table may already be dropped
219
219
  }
220
220
  }
221
+ // Migration: Add position column to tickets table (TKT-965)
222
+ if (!ticketsColumnNames.has('position')) {
223
+ try {
224
+ db.exec(`ALTER TABLE ${T.tickets} ADD COLUMN position INTEGER NOT NULL DEFAULT 0`);
225
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_pmo_tickets_status_position ON ${T.tickets}(status_id, position)`);
226
+ // Backfill existing tickets with gapped positions (1000, 2000, ...) per status
227
+ const statuses = db.prepare(`SELECT DISTINCT status_id FROM ${T.tickets} WHERE status_id IS NOT NULL`).all();
228
+ const getTicketsForStatus = db.prepare(`
229
+ SELECT id FROM ${T.tickets} WHERE status_id = ?
230
+ ORDER BY
231
+ CASE priority
232
+ WHEN 'P0' THEN 0
233
+ WHEN 'P1' THEN 1
234
+ WHEN 'P2' THEN 2
235
+ WHEN 'P3' THEN 3
236
+ ELSE 4
237
+ END,
238
+ created_at ASC
239
+ `);
240
+ const updatePosition = db.prepare(`UPDATE ${T.tickets} SET position = ? WHERE id = ?`);
241
+ for (const { status_id } of statuses) {
242
+ const tickets = getTicketsForStatus.all(status_id);
243
+ for (let i = 0; i < tickets.length; i++) {
244
+ updatePosition.run((i + 1) * 1000, tickets[i].id);
245
+ }
246
+ }
247
+ }
248
+ catch {
249
+ // Column may already exist
250
+ }
251
+ }
221
252
  // Migration: Reassign orphaned tickets (TKT-940)
222
253
  // Tickets with project_id that doesn't match any existing project are "orphaned".
223
254
  // This can happen when a 'default' project never existed or was deleted.
@@ -86,7 +86,7 @@ export class DependencyStorage {
86
86
  SELECT t.*,
87
87
  ws.id as column_id,
88
88
  ws.name as column_name,
89
- ws.position as position
89
+ t.position as position
90
90
  FROM ${T.tickets} t
91
91
  JOIN ${T.ticket_dependencies} d ON t.id = d.depends_on_ticket_id
92
92
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
@@ -102,7 +102,7 @@ export class DependencyStorage {
102
102
  SELECT t.*,
103
103
  ws.id as column_id,
104
104
  ws.name as column_name,
105
- ws.position as position
105
+ t.position as position
106
106
  FROM ${T.tickets} t
107
107
  JOIN ${T.ticket_dependencies} d ON t.id = d.ticket_id
108
108
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
@@ -179,20 +179,12 @@ export class EpicStorage {
179
179
  const rows = this.ctx.db.prepare(`
180
180
  SELECT t.*,
181
181
  ws.id as column_id,
182
- ws.position as position,
182
+ t.position as position,
183
183
  ws.name as column_name
184
184
  FROM ${T.tickets} t
185
185
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
186
186
  WHERE t.project_id = ? AND t.epic_id = ?
187
- ORDER BY ws.position,
188
- CASE t.priority
189
- WHEN 'P0' THEN 0
190
- WHEN 'P1' THEN 1
191
- WHEN 'P2' THEN 2
192
- WHEN 'P3' THEN 3
193
- ELSE 4
194
- END,
195
- t.created_at ASC
187
+ ORDER BY ws.position, t.position ASC, t.created_at ASC
196
188
  `).all(projectId, epicId);
197
189
  return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
198
190
  }
@@ -55,6 +55,10 @@ export declare class SQLiteStorage implements PMOStorage {
55
55
  getTicketById(id: string): Promise<Ticket | null>;
56
56
  updateTicket(id: string, changes: Partial<Ticket>): Promise<Ticket>;
57
57
  moveTicket(projectId: string, id: string, column: string, position?: number): Promise<Ticket>;
58
+ reorderTicket(id: string, opts: {
59
+ position?: number;
60
+ afterTicketId?: string;
61
+ }): Promise<Ticket>;
58
62
  moveTicketToProject(ticketId: string, newProjectId: string): Promise<Ticket>;
59
63
  deleteTicket(id: string): Promise<void>;
60
64
  listTickets(projectId: string | undefined, filter?: TicketFilter): Promise<Ticket[]>;
@@ -193,6 +193,9 @@ export class SQLiteStorage {
193
193
  async moveTicket(projectId, id, column, position) {
194
194
  return this.ticketStorage.moveTicket(projectId, id, column, position);
195
195
  }
196
+ async reorderTicket(id, opts) {
197
+ return this.ticketStorage.reorderTicket(id, opts);
198
+ }
196
199
  async moveTicketToProject(ticketId, newProjectId) {
197
200
  return this.ticketStorage.moveTicketToProject(ticketId, newProjectId);
198
201
  }
@@ -53,7 +53,7 @@ export declare class ProjectStorage {
53
53
  getProjectBoard(projectIdOrName: string): Promise<Board | null>;
54
54
  /**
55
55
  * Get tickets for a status (column).
56
- * Tickets are sorted by priority (P0 first) then created_at (oldest first).
56
+ * Tickets are sorted by position (force-ranked) then created_at as tiebreaker.
57
57
  */
58
58
  private getTicketsForStatus;
59
59
  /**
@@ -193,7 +193,7 @@ export class ProjectStorage {
193
193
  }
194
194
  /**
195
195
  * Get tickets for a status (column).
196
- * Tickets are sorted by priority (P0 first) then created_at (oldest first).
196
+ * Tickets are sorted by position (force-ranked) then created_at as tiebreaker.
197
197
  */
198
198
  async getTicketsForStatus(statusId, projectId) {
199
199
  const ticketRows = this.ctx.db.prepare(`
@@ -203,15 +203,7 @@ export class ProjectStorage {
203
203
  FROM ${T.tickets} t
204
204
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
205
205
  WHERE t.status_id = ? AND t.project_id = ?
206
- ORDER BY
207
- CASE t.priority
208
- WHEN 'P0' THEN 0
209
- WHEN 'P1' THEN 1
210
- WHEN 'P2' THEN 2
211
- WHEN 'P3' THEN 3
212
- ELSE 4
213
- END,
214
- t.created_at ASC
206
+ ORDER BY t.position ASC, t.created_at ASC
215
207
  `).all(statusId, projectId);
216
208
  return Promise.all(ticketRows.map((row) => rowToTicket(this.ctx.db, row)));
217
209
  }
@@ -236,20 +236,12 @@ export class SpecStorage {
236
236
  const rows = this.ctx.db.prepare(`
237
237
  SELECT t.*,
238
238
  ws.id as column_id,
239
- ws.position as position,
239
+ t.position as position,
240
240
  ws.name as column_name
241
241
  FROM ${T.tickets} t
242
242
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
243
243
  WHERE t.project_id = ? AND t.spec_id = ?
244
- ORDER BY ws.position,
245
- CASE t.priority
246
- WHEN 'P0' THEN 0
247
- WHEN 'P1' THEN 1
248
- WHEN 'P2' THEN 2
249
- WHEN 'P3' THEN 3
250
- ELSE 4
251
- END,
252
- t.created_at ASC
244
+ ORDER BY ws.position, t.position ASC, t.created_at ASC
253
245
  `).all(projectId, specId);
254
246
  return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
255
247
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Ticket operations for PMO.
3
3
  * Tickets reference workflow statuses directly via status_id.
4
- * Board position is derived from priority and created_at (no separate board_tickets table).
4
+ * Tickets have a position column for force-ranked ordering within a status.
5
+ * Positions use gapped integers (1000, 2000, 3000...) for stable reordering.
5
6
  */
6
7
  import { CreateTicketInput, Ticket, TicketFilter } from '../types.js';
7
8
  import { StorageContext } from './types.js';
@@ -50,9 +51,25 @@ export declare class TicketStorage {
50
51
  /**
51
52
  * Move a ticket to a different status (column).
52
53
  * In the workflow-based system, columns ARE statuses.
53
- * The position parameter is ignored - tickets are sorted by priority then created_at.
54
+ * If position is provided, the ticket is placed at that position.
55
+ * Otherwise, the ticket is appended to the end of the target status.
54
56
  */
55
- moveTicket(projectId: string, id: string, column: string, _position?: number): Promise<Ticket>;
57
+ moveTicket(projectId: string, id: string, column: string, position?: number): Promise<Ticket>;
58
+ /**
59
+ * Reorder a ticket within its current status.
60
+ * Supports two modes:
61
+ * 1. Direct position: set ticket to a specific position value
62
+ * 2. After ticket: place ticket immediately after another ticket
63
+ */
64
+ reorderTicket(id: string, opts: {
65
+ position?: number;
66
+ afterTicketId?: string;
67
+ }): Promise<Ticket>;
68
+ /**
69
+ * Re-gap positions for all tickets in a status using 1000-gaps.
70
+ * Optionally excludes a ticket (e.g., the one being moved).
71
+ */
72
+ private regapPositions;
56
73
  /**
57
74
  * Delete a ticket.
58
75
  * Works with ticket ID only - no project context required since ticket IDs are globally unique.