@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.
- package/dist/commands/work/start.js +30 -4
- package/dist/lib/agents/commands.d.ts +14 -0
- package/dist/lib/agents/commands.js +58 -0
- package/dist/lib/database/drizzle-schema.d.ts +17 -0
- package/dist/lib/database/drizzle-schema.js +2 -0
- package/dist/lib/database/index.js +39 -0
- package/dist/lib/execution/runners.js +9 -8
- package/dist/lib/mcp/helpers.d.ts +2 -0
- package/dist/lib/mcp/helpers.js +1 -0
- package/dist/lib/mcp/tools/ticket.js +25 -0
- package/dist/lib/pmo/schema.d.ts +2 -2
- package/dist/lib/pmo/schema.js +3 -0
- package/dist/lib/pmo/storage/base.js +31 -0
- package/dist/lib/pmo/storage/dependencies.js +2 -2
- package/dist/lib/pmo/storage/epics.js +2 -10
- package/dist/lib/pmo/storage/index.d.ts +4 -0
- package/dist/lib/pmo/storage/index.js +3 -0
- package/dist/lib/pmo/storage/projects.d.ts +1 -1
- package/dist/lib/pmo/storage/projects.js +2 -10
- package/dist/lib/pmo/storage/specs.js +2 -10
- package/dist/lib/pmo/storage/tickets.d.ts +20 -3
- package/dist/lib/pmo/storage/tickets.js +139 -33
- package/dist/lib/pmo/storage/views.js +2 -10
- package/dist/lib/pmo/types.d.ts +4 -0
- package/oclif.manifest.json +3014 -3014
- package/package.json +2 -2
|
@@ -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 (
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
-
//
|
|
869
|
-
const
|
|
870
|
-
// Write to container at /home/node/.claude.json
|
|
871
|
-
execSync(`docker exec ${containerId} bash -c '
|
|
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 '${
|
|
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;
|
package/dist/lib/mcp/helpers.js
CHANGED
|
@@ -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'),
|
package/dist/lib/pmo/schema.d.ts
CHANGED
|
@@ -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).
|
package/dist/lib/pmo/schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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.
|