@proletariat/cli 0.3.31 → 0.3.32
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/diet.d.ts +20 -0
- package/dist/commands/diet.js +181 -0
- package/dist/commands/mcp-server.js +2 -1
- package/dist/commands/priority/add.d.ts +15 -0
- package/dist/commands/priority/add.js +70 -0
- package/dist/commands/priority/list.d.ts +10 -0
- package/dist/commands/priority/list.js +34 -0
- package/dist/commands/priority/remove.d.ts +13 -0
- package/dist/commands/priority/remove.js +54 -0
- package/dist/commands/priority/set.d.ts +14 -0
- package/dist/commands/priority/set.js +60 -0
- package/dist/commands/pull.d.ts +23 -0
- package/dist/commands/pull.js +219 -0
- package/dist/commands/roadmap/generate.js +10 -5
- package/dist/commands/template/apply.js +5 -4
- package/dist/commands/template/create.js +9 -5
- package/dist/commands/ticket/create.js +6 -5
- package/dist/commands/ticket/edit.js +9 -9
- package/dist/commands/ticket/list.d.ts +2 -0
- package/dist/commands/ticket/list.js +20 -13
- package/dist/commands/ticket/update.js +8 -5
- package/dist/commands/work/spawn.d.ts +13 -0
- package/dist/commands/work/spawn.js +388 -1
- package/dist/lib/mcp/tools/diet.d.ts +6 -0
- package/dist/lib/mcp/tools/diet.js +261 -0
- package/dist/lib/mcp/tools/index.d.ts +1 -0
- package/dist/lib/mcp/tools/index.js +1 -0
- package/dist/lib/mcp/tools/template.js +1 -1
- package/dist/lib/mcp/tools/ticket.js +48 -3
- package/dist/lib/pmo/diet.d.ts +102 -0
- package/dist/lib/pmo/diet.js +127 -0
- package/dist/lib/pmo/storage/base.d.ts +5 -0
- package/dist/lib/pmo/storage/base.js +16 -0
- package/dist/lib/pmo/types.d.ts +12 -6
- package/dist/lib/pmo/types.js +6 -2
- package/dist/lib/pmo/utils.d.ts +40 -0
- package/dist/lib/pmo/utils.js +76 -0
- package/oclif.manifest.json +2686 -2348
- package/package.json +1 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Diet & Pull Tools
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { errorResponse, strictTool } from '../helpers.js';
|
|
6
|
+
import { loadDietConfig, saveDietConfig, parseDietString, formatDietConfig, } from '../../pmo/diet.js';
|
|
7
|
+
export function registerDietTools(server, ctx) {
|
|
8
|
+
strictTool(server, 'diet_show', 'Show current diet configuration and distribution report', {
|
|
9
|
+
project_id: z.string().optional().describe('Project ID (uses default if omitted)'),
|
|
10
|
+
}, async (params) => {
|
|
11
|
+
try {
|
|
12
|
+
const db = ctx.storage.getDatabase();
|
|
13
|
+
const dietConfig = loadDietConfig(db);
|
|
14
|
+
const projectId = params.project_id || (await getDefaultProjectId(ctx));
|
|
15
|
+
const report = await buildDietReport(ctx, projectId, dietConfig);
|
|
16
|
+
return {
|
|
17
|
+
content: [{
|
|
18
|
+
type: 'text',
|
|
19
|
+
text: JSON.stringify({
|
|
20
|
+
success: true,
|
|
21
|
+
diet: formatDietConfig(dietConfig),
|
|
22
|
+
ratios: dietConfig.ratios,
|
|
23
|
+
report: {
|
|
24
|
+
totalReady: report.totalReady,
|
|
25
|
+
uncategorized: report.uncategorized,
|
|
26
|
+
categories: report.categories,
|
|
27
|
+
},
|
|
28
|
+
}, null, 2),
|
|
29
|
+
}],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return errorResponse(error);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
strictTool(server, 'diet_set', 'Set diet weights (relative values normalized to percentages automatically)', {
|
|
37
|
+
ratios: z.string().describe('Diet weights as "category=weight,..." e.g. "ship=4,grow=2.5,support=1.5,bizops=1,strategy=1"'),
|
|
38
|
+
}, async (params) => {
|
|
39
|
+
try {
|
|
40
|
+
const db = ctx.storage.getDatabase();
|
|
41
|
+
const newConfig = parseDietString(params.ratios);
|
|
42
|
+
saveDietConfig(db, newConfig);
|
|
43
|
+
return {
|
|
44
|
+
content: [{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: JSON.stringify({
|
|
47
|
+
success: true,
|
|
48
|
+
diet: formatDietConfig(newConfig),
|
|
49
|
+
ratios: newConfig.ratios,
|
|
50
|
+
}, null, 2),
|
|
51
|
+
}],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return errorResponse(error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
strictTool(server, 'pull_tickets', 'Pull tickets from Backlog to Ready with diet ratio enforcement', {
|
|
59
|
+
project_id: z.string().optional().describe('Project ID (uses default if omitted)'),
|
|
60
|
+
count: z.number().optional().describe('Number of tickets to pull (default 50)'),
|
|
61
|
+
dry_run: z.boolean().optional().describe('If true, show what would be pulled without moving'),
|
|
62
|
+
}, async (params) => {
|
|
63
|
+
try {
|
|
64
|
+
const db = ctx.storage.getDatabase();
|
|
65
|
+
const dietConfig = loadDietConfig(db);
|
|
66
|
+
const projectId = params.project_id || (await getDefaultProjectId(ctx));
|
|
67
|
+
const count = params.count || 50;
|
|
68
|
+
const dryRun = params.dry_run || false;
|
|
69
|
+
const result = await runPull(ctx, projectId, dietConfig, count, dryRun);
|
|
70
|
+
return {
|
|
71
|
+
content: [{
|
|
72
|
+
type: 'text',
|
|
73
|
+
text: JSON.stringify({
|
|
74
|
+
success: true,
|
|
75
|
+
dryRun,
|
|
76
|
+
totalCandidates: result.totalCandidates,
|
|
77
|
+
skippedBlocked: result.skippedBlocked,
|
|
78
|
+
skippedCeiling: result.skippedCeiling,
|
|
79
|
+
pulled: result.pulled.map(t => ({
|
|
80
|
+
id: t.id,
|
|
81
|
+
title: t.title,
|
|
82
|
+
category: t.category,
|
|
83
|
+
pass: t.pass,
|
|
84
|
+
})),
|
|
85
|
+
pulledCount: result.pulled.length,
|
|
86
|
+
}, null, 2),
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return errorResponse(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Helpers
|
|
97
|
+
// =============================================================================
|
|
98
|
+
async function getDefaultProjectId(ctx) {
|
|
99
|
+
const projects = await ctx.storage.listProjects();
|
|
100
|
+
if (projects.length === 0)
|
|
101
|
+
throw new Error('No projects found');
|
|
102
|
+
return projects[0].id;
|
|
103
|
+
}
|
|
104
|
+
async function buildDietReport(ctx, projectId, dietConfig) {
|
|
105
|
+
const project = await ctx.storage.getProject(projectId);
|
|
106
|
+
if (!project)
|
|
107
|
+
throw new Error(`Project not found: ${projectId}`);
|
|
108
|
+
const workflowId = project.workflowId || 'default';
|
|
109
|
+
const statuses = await ctx.storage.listStatuses(workflowId);
|
|
110
|
+
const readyStatuses = statuses.filter((s) => s.category === 'unstarted');
|
|
111
|
+
const readyTickets = [];
|
|
112
|
+
for (const status of readyStatuses) {
|
|
113
|
+
const tickets = await ctx.storage.listTickets(projectId, { statusId: status.id });
|
|
114
|
+
readyTickets.push(...tickets);
|
|
115
|
+
}
|
|
116
|
+
const totalReady = readyTickets.length;
|
|
117
|
+
const categoryCounts = new Map();
|
|
118
|
+
let uncategorized = 0;
|
|
119
|
+
for (const ticket of readyTickets) {
|
|
120
|
+
const cat = (ticket.category || '').toLowerCase();
|
|
121
|
+
if (cat) {
|
|
122
|
+
categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
uncategorized++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const categories = dietConfig.ratios.map(ratio => {
|
|
129
|
+
const count = categoryCounts.get(ratio.category) || 0;
|
|
130
|
+
const actual = totalReady > 0 ? count / totalReady : 0;
|
|
131
|
+
const targetCount = Math.round(totalReady * ratio.target);
|
|
132
|
+
const delta = actual - ratio.target;
|
|
133
|
+
const tolerance = 0.05;
|
|
134
|
+
let status = 'ok';
|
|
135
|
+
if (delta > tolerance)
|
|
136
|
+
status = 'over';
|
|
137
|
+
else if (delta < -tolerance)
|
|
138
|
+
status = 'under';
|
|
139
|
+
return { category: ratio.category, target: ratio.target, actual, count, targetCount, delta, status };
|
|
140
|
+
});
|
|
141
|
+
return { categories, totalReady, uncategorized };
|
|
142
|
+
}
|
|
143
|
+
async function runPull(ctx, projectId, dietConfig, targetCount, dryRun) {
|
|
144
|
+
const project = await ctx.storage.getProject(projectId);
|
|
145
|
+
if (!project)
|
|
146
|
+
throw new Error(`Project not found: ${projectId}`);
|
|
147
|
+
const workflowId = project.workflowId || 'default';
|
|
148
|
+
const statuses = await ctx.storage.listStatuses(workflowId);
|
|
149
|
+
const backlogStatuses = statuses.filter((s) => s.category === 'backlog');
|
|
150
|
+
const readyStatuses = statuses.filter((s) => s.category === 'unstarted');
|
|
151
|
+
if (backlogStatuses.length === 0)
|
|
152
|
+
throw new Error('No backlog statuses found');
|
|
153
|
+
if (readyStatuses.length === 0)
|
|
154
|
+
throw new Error('No ready/unstarted statuses found');
|
|
155
|
+
const targetStatus = readyStatuses.sort((a, b) => a.position - b.position)[0];
|
|
156
|
+
// Get backlog tickets sorted by position
|
|
157
|
+
const backlogTickets = [];
|
|
158
|
+
for (const status of backlogStatuses) {
|
|
159
|
+
const tickets = await ctx.storage.listTickets(projectId, { statusId: status.id });
|
|
160
|
+
backlogTickets.push(...tickets);
|
|
161
|
+
}
|
|
162
|
+
backlogTickets.sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
163
|
+
// Get existing ready tickets
|
|
164
|
+
const existingReady = [];
|
|
165
|
+
for (const status of readyStatuses) {
|
|
166
|
+
const tickets = await ctx.storage.listTickets(projectId, { statusId: status.id });
|
|
167
|
+
existingReady.push(...tickets);
|
|
168
|
+
}
|
|
169
|
+
// Pull algorithm
|
|
170
|
+
const pulled = [];
|
|
171
|
+
let skippedBlocked = 0;
|
|
172
|
+
let skippedCeiling = 0;
|
|
173
|
+
const categoryCounts = new Map();
|
|
174
|
+
for (const ratio of dietConfig.ratios) {
|
|
175
|
+
categoryCounts.set(ratio.category, 0);
|
|
176
|
+
}
|
|
177
|
+
for (const ticket of existingReady) {
|
|
178
|
+
const cat = (ticket.category || '').toLowerCase();
|
|
179
|
+
if (cat)
|
|
180
|
+
categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
const pulledIds = new Set();
|
|
183
|
+
const totalTarget = existingReady.length + targetCount;
|
|
184
|
+
const getCeiling = (category) => {
|
|
185
|
+
const ratio = dietConfig.ratios.find(r => r.category === category);
|
|
186
|
+
if (!ratio)
|
|
187
|
+
return targetCount;
|
|
188
|
+
return Math.ceil(totalTarget * ratio.target);
|
|
189
|
+
};
|
|
190
|
+
// Pass 1
|
|
191
|
+
const remainingBacklog = [];
|
|
192
|
+
for (const ticket of backlogTickets) {
|
|
193
|
+
if (pulled.length >= targetCount)
|
|
194
|
+
break;
|
|
195
|
+
const blocked = await ctx.storage.isTicketBlocked(ticket.id);
|
|
196
|
+
if (blocked) {
|
|
197
|
+
skippedBlocked++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const cat = (ticket.category || '').toLowerCase();
|
|
201
|
+
const currentCount = categoryCounts.get(cat) || 0;
|
|
202
|
+
const ceiling = getCeiling(cat);
|
|
203
|
+
if (currentCount < ceiling) {
|
|
204
|
+
pulled.push({
|
|
205
|
+
id: ticket.id,
|
|
206
|
+
title: ticket.title,
|
|
207
|
+
category: ticket.category || undefined,
|
|
208
|
+
position: ticket.position || 0,
|
|
209
|
+
pass: 'first',
|
|
210
|
+
});
|
|
211
|
+
pulledIds.add(ticket.id);
|
|
212
|
+
categoryCounts.set(cat, currentCount + 1);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
skippedCeiling++;
|
|
216
|
+
remainingBacklog.push(ticket);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Pass 2
|
|
220
|
+
if (pulled.length < targetCount) {
|
|
221
|
+
for (const ratio of dietConfig.ratios) {
|
|
222
|
+
if (pulled.length >= targetCount)
|
|
223
|
+
break;
|
|
224
|
+
const currentCount = categoryCounts.get(ratio.category) || 0;
|
|
225
|
+
const targetForCat = Math.ceil(totalTarget * ratio.target);
|
|
226
|
+
if (currentCount < targetForCat) {
|
|
227
|
+
const catTickets = remainingBacklog.filter(t => (t.category || '').toLowerCase() === ratio.category && !pulledIds.has(t.id));
|
|
228
|
+
for (const ticket of catTickets) {
|
|
229
|
+
if (pulled.length >= targetCount)
|
|
230
|
+
break;
|
|
231
|
+
if ((categoryCounts.get(ratio.category) || 0) >= targetForCat)
|
|
232
|
+
break;
|
|
233
|
+
const blocked = await ctx.storage.isTicketBlocked(ticket.id);
|
|
234
|
+
if (blocked)
|
|
235
|
+
continue;
|
|
236
|
+
pulled.push({
|
|
237
|
+
id: ticket.id,
|
|
238
|
+
title: ticket.title,
|
|
239
|
+
category: ticket.category || undefined,
|
|
240
|
+
position: ticket.position || 0,
|
|
241
|
+
pass: 'second',
|
|
242
|
+
});
|
|
243
|
+
pulledIds.add(ticket.id);
|
|
244
|
+
categoryCounts.set(ratio.category, (categoryCounts.get(ratio.category) || 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Move tickets if not dry run
|
|
250
|
+
if (!dryRun) {
|
|
251
|
+
for (const ticket of pulled) {
|
|
252
|
+
await ctx.storage.moveTicket(projectId, ticket.id, targetStatus.name);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
pulled,
|
|
257
|
+
skippedBlocked,
|
|
258
|
+
skippedCeiling,
|
|
259
|
+
totalCandidates: backlogTickets.length,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -15,4 +15,5 @@ export { registerRoadmapTools } from './roadmap.js';
|
|
|
15
15
|
export { registerCategoryTools } from './category.js';
|
|
16
16
|
export { registerTemplateTools } from './template.js';
|
|
17
17
|
export { registerViewTools } from './view.js';
|
|
18
|
+
export { registerDietTools } from './diet.js';
|
|
18
19
|
export { registerAgentTools, registerDockerTools, registerRepoTools, registerBranchTools, registerGitHubTools, registerInitTools, registerUtilityTools, } from './cli-passthrough.js';
|
|
@@ -15,5 +15,6 @@ export { registerRoadmapTools } from './roadmap.js';
|
|
|
15
15
|
export { registerCategoryTools } from './category.js';
|
|
16
16
|
export { registerTemplateTools } from './template.js';
|
|
17
17
|
export { registerViewTools } from './view.js';
|
|
18
|
+
export { registerDietTools } from './diet.js';
|
|
18
19
|
// CLI passthrough tools
|
|
19
20
|
export { registerAgentTools, registerDockerTools, registerRepoTools, registerBranchTools, registerGitHubTools, registerInitTools, registerUtilityTools, } from './cli-passthrough.js';
|
|
@@ -49,7 +49,7 @@ export function registerTemplateTools(server, ctx) {
|
|
|
49
49
|
description: z.string().optional(),
|
|
50
50
|
title_pattern: z.string().optional(),
|
|
51
51
|
description_template: z.string().optional(),
|
|
52
|
-
default_priority: z.
|
|
52
|
+
default_priority: z.string().optional().describe('Default priority (uses workspace priority scale)'),
|
|
53
53
|
default_category: z.string().optional(),
|
|
54
54
|
}, async (params) => {
|
|
55
55
|
try {
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatTicket, formatTicketFull, errorResponse, strictTool } from '../helpers.js';
|
|
6
|
+
import { getWorkspacePriorities, setWorkspacePriorities } from '../../pmo/utils.js';
|
|
6
7
|
export function registerTicketTools(server, ctx) {
|
|
7
8
|
strictTool(server, 'ticket_list', 'List tickets with optional filters', {
|
|
8
9
|
project: z.string().optional().describe('Project ID'),
|
|
9
10
|
column: z.string().optional().describe('Filter by column/status'),
|
|
10
|
-
priority: z.
|
|
11
|
+
priority: z.string().optional().describe('Filter by priority (uses workspace priority scale)'),
|
|
11
12
|
category: z.string().optional().describe('Filter by category'),
|
|
12
13
|
assignee: z.string().optional().describe('Filter by assignee'),
|
|
13
14
|
owner: z.string().optional().describe('Filter by owner'),
|
|
@@ -61,7 +62,7 @@ export function registerTicketTools(server, ctx) {
|
|
|
61
62
|
title: z.string().describe('Ticket title (required)'),
|
|
62
63
|
project: z.string().optional().describe('Project ID'),
|
|
63
64
|
description: z.string().optional().describe('Ticket description'),
|
|
64
|
-
priority: z.
|
|
65
|
+
priority: z.string().optional().describe('Priority (uses workspace priority scale)'),
|
|
65
66
|
category: z.string().optional().describe('Category (feature, bug, etc.)'),
|
|
66
67
|
column: z.string().optional().describe('Column/status name'),
|
|
67
68
|
assignee: z.string().optional().describe('Assignee'),
|
|
@@ -121,7 +122,7 @@ export function registerTicketTools(server, ctx) {
|
|
|
121
122
|
id: z.string().describe('Ticket ID'),
|
|
122
123
|
title: z.string().optional().describe('New title'),
|
|
123
124
|
description: z.string().optional().describe('New description'),
|
|
124
|
-
priority: z.
|
|
125
|
+
priority: z.string().optional().describe('New priority (uses workspace priority scale)'),
|
|
125
126
|
category: z.string().optional().describe('New category'),
|
|
126
127
|
assignee: z.string().optional().describe('New assignee'),
|
|
127
128
|
owner: z.string().optional().describe('New owner'),
|
|
@@ -415,4 +416,48 @@ export function registerTicketTools(server, ctx) {
|
|
|
415
416
|
return errorResponse(error);
|
|
416
417
|
}
|
|
417
418
|
});
|
|
419
|
+
strictTool(server, 'priority_list', 'List the workspace priority scale (ordered from highest to lowest)', {}, async () => {
|
|
420
|
+
try {
|
|
421
|
+
const db = ctx.storage.getDatabase();
|
|
422
|
+
const priorities = getWorkspacePriorities(db);
|
|
423
|
+
return {
|
|
424
|
+
content: [{
|
|
425
|
+
type: 'text',
|
|
426
|
+
text: JSON.stringify({ success: true, priorities }, null, 2),
|
|
427
|
+
}],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
return errorResponse(error);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
strictTool(server, 'priority_set', 'Set the workspace priority scale (replaces all existing priorities)', {
|
|
435
|
+
priorities: z.array(z.string()).min(1).describe('Priority values from highest to lowest'),
|
|
436
|
+
}, async (params) => {
|
|
437
|
+
try {
|
|
438
|
+
const db = ctx.storage.getDatabase();
|
|
439
|
+
// Check for duplicates
|
|
440
|
+
const seen = new Set();
|
|
441
|
+
for (const p of params.priorities) {
|
|
442
|
+
if (seen.has(p))
|
|
443
|
+
throw new Error(`Duplicate priority value: "${p}"`);
|
|
444
|
+
seen.add(p);
|
|
445
|
+
}
|
|
446
|
+
const oldPriorities = getWorkspacePriorities(db);
|
|
447
|
+
setWorkspacePriorities(db, params.priorities);
|
|
448
|
+
return {
|
|
449
|
+
content: [{
|
|
450
|
+
type: 'text',
|
|
451
|
+
text: JSON.stringify({
|
|
452
|
+
success: true,
|
|
453
|
+
previous: oldPriorities,
|
|
454
|
+
priorities: params.priorities,
|
|
455
|
+
}, null, 2),
|
|
456
|
+
}],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
return errorResponse(error);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
418
463
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diet configuration for balanced ticket pulling.
|
|
3
|
+
*
|
|
4
|
+
* The "diet" defines target ratios for business categories (e.g., ship, grow, support).
|
|
5
|
+
* Users specify relative weights (e.g., ship=4, grow=2, support=1) which are
|
|
6
|
+
* normalized to percentages internally. No need to sum to 100.
|
|
7
|
+
*/
|
|
8
|
+
import Database from 'better-sqlite3';
|
|
9
|
+
/**
|
|
10
|
+
* Diet ratio for a single category.
|
|
11
|
+
* `weight` is the user-facing relative weight.
|
|
12
|
+
* `target` is the normalized percentage (0.0-1.0), computed from weights.
|
|
13
|
+
*/
|
|
14
|
+
export interface DietRatio {
|
|
15
|
+
category: string;
|
|
16
|
+
weight: number;
|
|
17
|
+
target: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Full diet configuration.
|
|
21
|
+
*/
|
|
22
|
+
export interface DietConfig {
|
|
23
|
+
ratios: DietRatio[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Diet report for a single category showing actual vs target.
|
|
27
|
+
*/
|
|
28
|
+
export interface DietCategoryReport {
|
|
29
|
+
category: string;
|
|
30
|
+
target: number;
|
|
31
|
+
actual: number;
|
|
32
|
+
count: number;
|
|
33
|
+
targetCount: number;
|
|
34
|
+
delta: number;
|
|
35
|
+
status: 'over' | 'under' | 'ok';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Full diet report.
|
|
39
|
+
*/
|
|
40
|
+
export interface DietReport {
|
|
41
|
+
categories: DietCategoryReport[];
|
|
42
|
+
totalReady: number;
|
|
43
|
+
uncategorized: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Result of a pull operation.
|
|
47
|
+
*/
|
|
48
|
+
export interface PullResult {
|
|
49
|
+
pulled: PulledTicket[];
|
|
50
|
+
skippedBlocked: number;
|
|
51
|
+
skippedCeiling: number;
|
|
52
|
+
totalCandidates: number;
|
|
53
|
+
}
|
|
54
|
+
export interface PulledTicket {
|
|
55
|
+
id: string;
|
|
56
|
+
title: string;
|
|
57
|
+
category: string | undefined;
|
|
58
|
+
position: number;
|
|
59
|
+
pass: 'first' | 'second';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Default diet weights matching the 5-Tool Founder business categories.
|
|
63
|
+
* Weights: ship=4, grow=2.5, support=1.5, bizops=1, strategy=1
|
|
64
|
+
* Normalizes to: ship=40%, grow=25%, support=15%, bizops=10%, strategy=10%
|
|
65
|
+
*/
|
|
66
|
+
export declare const DEFAULT_DIET_WEIGHTS: Array<{
|
|
67
|
+
category: string;
|
|
68
|
+
weight: number;
|
|
69
|
+
}>;
|
|
70
|
+
export declare const DEFAULT_DIET_CONFIG: DietConfig;
|
|
71
|
+
/**
|
|
72
|
+
* Normalize an array of category weights into a DietConfig with target percentages.
|
|
73
|
+
*/
|
|
74
|
+
export declare function normalizeWeights(weights: Array<{
|
|
75
|
+
category: string;
|
|
76
|
+
weight: number;
|
|
77
|
+
}>): DietConfig;
|
|
78
|
+
/**
|
|
79
|
+
* Load diet configuration from the database.
|
|
80
|
+
* Returns default config if none is stored.
|
|
81
|
+
*
|
|
82
|
+
* Handles backward compatibility: if stored config has ratios without weights,
|
|
83
|
+
* reconstructs weights from target percentages.
|
|
84
|
+
*/
|
|
85
|
+
export declare function loadDietConfig(db: Database.Database): DietConfig;
|
|
86
|
+
/**
|
|
87
|
+
* Save diet configuration to the database.
|
|
88
|
+
*/
|
|
89
|
+
export declare function saveDietConfig(db: Database.Database, config: DietConfig): void;
|
|
90
|
+
/**
|
|
91
|
+
* Parse a diet string like "ship=4,grow=2.5,support=1.5,bizops=1,strategy=1"
|
|
92
|
+
* into a DietConfig. Values are relative weights that get normalized internally.
|
|
93
|
+
*/
|
|
94
|
+
export declare function parseDietString(input: string): DietConfig;
|
|
95
|
+
/**
|
|
96
|
+
* Format a diet config showing weights and computed percentages.
|
|
97
|
+
*/
|
|
98
|
+
export declare function formatDietConfig(config: DietConfig): string;
|
|
99
|
+
/**
|
|
100
|
+
* Format a diet config showing only the weights (compact form).
|
|
101
|
+
*/
|
|
102
|
+
export declare function formatDietWeights(config: DietConfig): string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diet configuration for balanced ticket pulling.
|
|
3
|
+
*
|
|
4
|
+
* The "diet" defines target ratios for business categories (e.g., ship, grow, support).
|
|
5
|
+
* Users specify relative weights (e.g., ship=4, grow=2, support=1) which are
|
|
6
|
+
* normalized to percentages internally. No need to sum to 100.
|
|
7
|
+
*/
|
|
8
|
+
import { PMO_TABLES } from './schema.js';
|
|
9
|
+
const T = PMO_TABLES;
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Default Diet
|
|
12
|
+
// =============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Default diet weights matching the 5-Tool Founder business categories.
|
|
15
|
+
* Weights: ship=4, grow=2.5, support=1.5, bizops=1, strategy=1
|
|
16
|
+
* Normalizes to: ship=40%, grow=25%, support=15%, bizops=10%, strategy=10%
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_DIET_WEIGHTS = [
|
|
19
|
+
{ category: 'ship', weight: 4 },
|
|
20
|
+
{ category: 'grow', weight: 2.5 },
|
|
21
|
+
{ category: 'support', weight: 1.5 },
|
|
22
|
+
{ category: 'bizops', weight: 1 },
|
|
23
|
+
{ category: 'strategy', weight: 1 },
|
|
24
|
+
];
|
|
25
|
+
export const DEFAULT_DIET_CONFIG = normalizeWeights(DEFAULT_DIET_WEIGHTS);
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Weight Normalization
|
|
28
|
+
// =============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* Normalize an array of category weights into a DietConfig with target percentages.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeWeights(weights) {
|
|
33
|
+
const totalWeight = weights.reduce((sum, w) => sum + w.weight, 0);
|
|
34
|
+
if (totalWeight === 0) {
|
|
35
|
+
throw new Error('Total weight must be greater than 0');
|
|
36
|
+
}
|
|
37
|
+
const ratios = weights.map(w => ({
|
|
38
|
+
category: w.category,
|
|
39
|
+
weight: w.weight,
|
|
40
|
+
target: w.weight / totalWeight,
|
|
41
|
+
}));
|
|
42
|
+
return { ratios };
|
|
43
|
+
}
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// Diet Storage (pmo_settings table)
|
|
46
|
+
// =============================================================================
|
|
47
|
+
const DIET_CONFIG_KEY = 'diet_config';
|
|
48
|
+
/**
|
|
49
|
+
* Load diet configuration from the database.
|
|
50
|
+
* Returns default config if none is stored.
|
|
51
|
+
*
|
|
52
|
+
* Handles backward compatibility: if stored config has ratios without weights,
|
|
53
|
+
* reconstructs weights from target percentages.
|
|
54
|
+
*/
|
|
55
|
+
export function loadDietConfig(db) {
|
|
56
|
+
try {
|
|
57
|
+
const row = db.prepare(`SELECT value FROM ${T.settings} WHERE key = ?`).get(DIET_CONFIG_KEY);
|
|
58
|
+
if (row) {
|
|
59
|
+
const parsed = JSON.parse(row.value);
|
|
60
|
+
if (parsed.ratios && Array.isArray(parsed.ratios)) {
|
|
61
|
+
// Backward compatibility: if weights are missing, reconstruct from targets
|
|
62
|
+
const needsWeights = parsed.ratios.some(r => r.weight === undefined || r.weight === null);
|
|
63
|
+
if (needsWeights) {
|
|
64
|
+
const weights = parsed.ratios.map(r => ({
|
|
65
|
+
category: r.category,
|
|
66
|
+
weight: r.weight ?? Math.round(r.target * 100),
|
|
67
|
+
}));
|
|
68
|
+
return normalizeWeights(weights);
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Fall through to default
|
|
76
|
+
}
|
|
77
|
+
return DEFAULT_DIET_CONFIG;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Save diet configuration to the database.
|
|
81
|
+
*/
|
|
82
|
+
export function saveDietConfig(db, config) {
|
|
83
|
+
db.prepare(`INSERT OR REPLACE INTO ${T.settings} (key, value) VALUES (?, ?)`).run(DIET_CONFIG_KEY, JSON.stringify(config));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse a diet string like "ship=4,grow=2.5,support=1.5,bizops=1,strategy=1"
|
|
87
|
+
* into a DietConfig. Values are relative weights that get normalized internally.
|
|
88
|
+
*/
|
|
89
|
+
export function parseDietString(input) {
|
|
90
|
+
const parts = input.split(',').map(s => s.trim()).filter(Boolean);
|
|
91
|
+
const weights = [];
|
|
92
|
+
for (const part of parts) {
|
|
93
|
+
const [category, weightStr] = part.split('=').map(s => s.trim());
|
|
94
|
+
if (!category || !weightStr) {
|
|
95
|
+
throw new Error(`Invalid diet entry: "${part}". Expected format: category=weight`);
|
|
96
|
+
}
|
|
97
|
+
const weight = Number.parseFloat(weightStr);
|
|
98
|
+
if (Number.isNaN(weight) || weight < 0) {
|
|
99
|
+
throw new Error(`Invalid weight for "${category}": ${weightStr}. Must be a non-negative number.`);
|
|
100
|
+
}
|
|
101
|
+
weights.push({ category: category.toLowerCase(), weight });
|
|
102
|
+
}
|
|
103
|
+
if (weights.length === 0) {
|
|
104
|
+
throw new Error('At least one category weight is required');
|
|
105
|
+
}
|
|
106
|
+
const totalWeight = weights.reduce((sum, w) => sum + w.weight, 0);
|
|
107
|
+
if (totalWeight === 0) {
|
|
108
|
+
throw new Error('At least one category must have a weight greater than 0');
|
|
109
|
+
}
|
|
110
|
+
return normalizeWeights(weights);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Format a diet config showing weights and computed percentages.
|
|
114
|
+
*/
|
|
115
|
+
export function formatDietConfig(config) {
|
|
116
|
+
return config.ratios
|
|
117
|
+
.map(r => `${r.category}=${r.weight} (${Math.round(r.target * 100)}%)`)
|
|
118
|
+
.join(', ');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Format a diet config showing only the weights (compact form).
|
|
122
|
+
*/
|
|
123
|
+
export function formatDietWeights(config) {
|
|
124
|
+
return config.ratios
|
|
125
|
+
.map(r => `${r.category}=${r.weight}`)
|
|
126
|
+
.join(', ');
|
|
127
|
+
}
|
|
@@ -37,6 +37,11 @@ export declare function seedBuiltinTicketTemplates(db: Database.Database): void;
|
|
|
37
37
|
* Seed built-in categories from TICKET_CATEGORIES and STATE_CATEGORY_ORDER.
|
|
38
38
|
*/
|
|
39
39
|
export declare function seedBuiltinCategories(db: Database.Database): void;
|
|
40
|
+
/**
|
|
41
|
+
* Seed default priorities if not already set.
|
|
42
|
+
* Preserves any existing user-defined priority scale.
|
|
43
|
+
*/
|
|
44
|
+
export declare function seedDefaultPriorities(db: Database.Database): void;
|
|
40
45
|
/**
|
|
41
46
|
* Update board timestamp for a project.
|
|
42
47
|
*/
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { PMO_TABLES, PMO_SCHEMA_SQL, validateTicketSchema } from '../schema.js';
|
|
6
6
|
import { TICKET_CATEGORIES, STATE_CATEGORY_ORDER } from '../types.js';
|
|
7
7
|
import { BUILTIN_TEMPLATES } from '../templates-builtin.js';
|
|
8
|
+
import { getWorkspacePriorities, setWorkspacePriorities, DEFAULT_PRIORITIES } from '../utils.js';
|
|
8
9
|
const T = PMO_TABLES;
|
|
9
10
|
/**
|
|
10
11
|
* Initialize PMO tables in the database.
|
|
@@ -18,6 +19,7 @@ export function initializePMOTables(db) {
|
|
|
18
19
|
seedBuiltinPhaseTemplates(db);
|
|
19
20
|
seedBuiltinActions(db);
|
|
20
21
|
seedBuiltinTicketTemplates(db);
|
|
22
|
+
seedDefaultPriorities(db); // Seed default priority scale if not set
|
|
21
23
|
validateTicketSchema(db);
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
@@ -913,6 +915,20 @@ export function seedBuiltinCategories(db) {
|
|
|
913
915
|
insertCategory.run(id, category, 'status', statusCategoryDescriptions[category] || null, i, now);
|
|
914
916
|
}
|
|
915
917
|
}
|
|
918
|
+
/**
|
|
919
|
+
* Seed default priorities if not already set.
|
|
920
|
+
* Preserves any existing user-defined priority scale.
|
|
921
|
+
*/
|
|
922
|
+
export function seedDefaultPriorities(db) {
|
|
923
|
+
const existing = getWorkspacePriorities(db);
|
|
924
|
+
// getWorkspacePriorities returns DEFAULT_PRIORITIES if not set,
|
|
925
|
+
// but we need to check if it's actually stored in the DB
|
|
926
|
+
const row = db.prepare(`SELECT value FROM ${T.settings} WHERE key = 'priorities'`).get();
|
|
927
|
+
if (!row) {
|
|
928
|
+
// No priorities set yet - seed with defaults
|
|
929
|
+
setWorkspacePriorities(db, [...DEFAULT_PRIORITIES]);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
916
932
|
/**
|
|
917
933
|
* Update board timestamp for a project.
|
|
918
934
|
*/
|
package/dist/lib/pmo/types.d.ts
CHANGED
|
@@ -59,17 +59,23 @@ export interface Epic {
|
|
|
59
59
|
*/
|
|
60
60
|
export type TicketStatus = string;
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Priority is now a string to support user-defined priority scales.
|
|
63
|
+
* The workspace's priority scale is stored in pmo_settings.
|
|
64
|
+
* Use getWorkspacePriorities() from utils.ts to get the current scale.
|
|
63
65
|
*/
|
|
64
|
-
export type Priority =
|
|
66
|
+
export type Priority = string;
|
|
65
67
|
/**
|
|
66
|
-
*
|
|
68
|
+
* Default priority values as a const array.
|
|
69
|
+
* @deprecated Use getWorkspacePriorities() from utils.ts instead for runtime priority resolution.
|
|
70
|
+
* This constant is kept for backwards compatibility and as the default when no workspace scale is set.
|
|
67
71
|
*/
|
|
68
|
-
export declare const PRIORITIES: readonly
|
|
72
|
+
export declare const PRIORITIES: readonly string[];
|
|
69
73
|
/**
|
|
70
|
-
*
|
|
74
|
+
* Default priority display names for UI presentation.
|
|
75
|
+
* @deprecated Priority labels are derived from the workspace priority scale.
|
|
76
|
+
* For user-defined priorities, the label IS the priority value.
|
|
71
77
|
*/
|
|
72
|
-
export declare const PRIORITY_LABELS: Record<
|
|
78
|
+
export declare const PRIORITY_LABELS: Record<string, string>;
|
|
73
79
|
/**
|
|
74
80
|
* Legacy priority values for backwards compatibility.
|
|
75
81
|
*/
|