argustack 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/csv/mapper.d.ts.map +1 -1
  3. package/dist/adapters/csv/mapper.js +53 -21
  4. package/dist/adapters/csv/mapper.js.map +1 -1
  5. package/dist/adapters/git/provider.d.ts.map +1 -1
  6. package/dist/adapters/git/provider.js +1 -3
  7. package/dist/adapters/git/provider.js.map +1 -1
  8. package/dist/adapters/jira/mapper.d.ts.map +1 -1
  9. package/dist/adapters/jira/mapper.js +19 -2
  10. package/dist/adapters/jira/mapper.js.map +1 -1
  11. package/dist/adapters/jira/provider.d.ts.map +1 -1
  12. package/dist/adapters/jira/provider.js +0 -4
  13. package/dist/adapters/jira/provider.js.map +1 -1
  14. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  15. package/dist/adapters/postgres/schema.js +14 -5
  16. package/dist/adapters/postgres/schema.js.map +1 -1
  17. package/dist/adapters/postgres/storage.d.ts.map +1 -1
  18. package/dist/adapters/postgres/storage.js +15 -14
  19. package/dist/adapters/postgres/storage.js.map +1 -1
  20. package/dist/cli/index.js +4 -7
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/init.d.ts.map +1 -1
  23. package/dist/cli/init.js +29 -38
  24. package/dist/cli/init.js.map +1 -1
  25. package/dist/cli/mcp-install.d.ts.map +1 -1
  26. package/dist/cli/mcp-install.js +0 -21
  27. package/dist/cli/mcp-install.js.map +1 -1
  28. package/dist/cli/sources.d.ts.map +1 -1
  29. package/dist/cli/sources.js +0 -5
  30. package/dist/cli/sources.js.map +1 -1
  31. package/dist/cli/status.d.ts.map +1 -1
  32. package/dist/cli/status.js +1 -4
  33. package/dist/cli/status.js.map +1 -1
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +0 -3
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/core/types/issue.d.ts +5 -0
  38. package/dist/core/types/issue.d.ts.map +1 -1
  39. package/dist/mcp/server.d.ts.map +1 -1
  40. package/dist/mcp/server.js +269 -101
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/use-cases/embed.d.ts.map +1 -1
  43. package/dist/use-cases/embed.js +1 -3
  44. package/dist/use-cases/embed.js.map +1 -1
  45. package/dist/use-cases/pull-git.d.ts.map +1 -1
  46. package/dist/use-cases/pull-git.js +2 -6
  47. package/dist/use-cases/pull-git.js.map +1 -1
  48. package/dist/use-cases/pull-github.d.ts.map +1 -1
  49. package/dist/use-cases/pull-github.js +2 -6
  50. package/dist/use-cases/pull-github.js.map +1 -1
  51. package/dist/use-cases/pull.d.ts.map +1 -1
  52. package/dist/use-cases/pull.js +2 -13
  53. package/dist/use-cases/pull.js.map +1 -1
  54. package/dist/workspace/config.d.ts.map +1 -1
  55. package/dist/workspace/config.js +0 -3
  56. package/dist/workspace/config.js.map +1 -1
  57. package/dist/workspace/resolver.d.ts.map +1 -1
  58. package/dist/workspace/resolver.js +0 -1
  59. package/dist/workspace/resolver.js.map +1 -1
  60. package/package.json +1 -1
  61. package/templates/init.sql +5 -0
@@ -19,7 +19,6 @@ import dotenv from 'dotenv';
19
19
  import { findWorkspaceRoot } from '../workspace/resolver.js';
20
20
  import { readConfig, getEnabledSources } from '../workspace/config.js';
21
21
  import { SOURCE_META } from '../core/types/index.js';
22
- // ─── Helpers ──────────────────────────────────────────────────────────────────
23
22
  /** Extract error message from an unknown catch value */
24
23
  function getErrorMessage(err) {
25
24
  if (err instanceof Error) {
@@ -41,7 +40,6 @@ function str(value) {
41
40
  if (value instanceof Date) {
42
41
  return value.toISOString();
43
42
  }
44
- // Objects, arrays, symbols, functions — use JSON for a meaningful representation
45
43
  return JSON.stringify(value);
46
44
  }
47
45
  /** Load workspace context with diagnostic info on failure */
@@ -87,7 +85,6 @@ async function createAdapters(workspaceRoot) {
87
85
  });
88
86
  return { source, storage };
89
87
  }
90
- // ─── Icon ────────────────────────────────────────────────────────────────────
91
88
  const mcpFilename = fileURLToPath(import.meta.url);
92
89
  const mcpPackageRoot = resolve(dirname(mcpFilename), '..', '..');
93
90
  function loadIconDataUri() {
@@ -99,7 +96,6 @@ function loadIconDataUri() {
99
96
  return `data:image/png;base64,${buf.toString('base64')}`;
100
97
  }
101
98
  const iconDataUri = loadIconDataUri();
102
- // ─── Server ───────────────────────────────────────────────────────────────────
103
99
  /** MCP server instance — exported for testing via InMemoryTransport */
104
100
  export const server = new McpServer({
105
101
  name: 'Argustack',
@@ -113,7 +109,6 @@ export const server = new McpServer({
113
109
  }],
114
110
  } : {}),
115
111
  });
116
- // ─── Tool: workspace_info ─────────────────────────────────────────────────────
117
112
  server.registerTool('workspace_info', {
118
113
  description: 'Get information about the current Argustack workspace — configured sources, paths, database connection',
119
114
  }, () => {
@@ -141,7 +136,6 @@ server.registerTool('workspace_info', {
141
136
  ].join('\n');
142
137
  return { content: [{ type: 'text', text }] };
143
138
  });
144
- // ─── Tool: list_projects ──────────────────────────────────────────────────────
145
139
  server.registerTool('list_projects', {
146
140
  description: 'List all Jira projects available in the configured Jira instance',
147
141
  }, async () => {
@@ -176,7 +170,6 @@ server.registerTool('list_projects', {
176
170
  };
177
171
  }
178
172
  });
179
- // ─── Tool: pull_jira ──────────────────────────────────────────────────────────
180
173
  server.registerTool('pull_jira', {
181
174
  description: 'Pull all issues from Jira into Argustack PostgreSQL database. Supports incremental pulls (only new/updated issues). Use project parameter to pull a specific project.',
182
175
  inputSchema: {
@@ -230,7 +223,6 @@ server.registerTool('pull_jira', {
230
223
  };
231
224
  }
232
225
  });
233
- // ─── Tool: query_issues ───────────────────────────────────────────────────────
234
226
  server.registerTool('query_issues', {
235
227
  description: 'Search and query Jira issues stored in the local Argustack database. Supports full-text search, filtering by project/status/assignee, and SQL for complex queries.',
236
228
  inputSchema: {
@@ -256,12 +248,10 @@ server.registerTool('query_issues', {
256
248
  let sqlQuery;
257
249
  let params;
258
250
  if (sql) {
259
- // Raw SQL mode — for power users / Claude
260
251
  sqlQuery = sql;
261
252
  params = [];
262
253
  }
263
254
  else {
264
- // Build query from filters
265
255
  const conditions = [];
266
256
  params = [];
267
257
  let paramIdx = 1;
@@ -291,7 +281,6 @@ server.registerTool('query_issues', {
291
281
  paramIdx++;
292
282
  }
293
283
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
294
- // LIMIT is safe — maxResults is always a number from z.number() or default 50
295
284
  sqlQuery = `
296
285
  SELECT issue_key, summary, status, priority, assignee, issue_type,
297
286
  project_key, created, updated
@@ -308,13 +297,11 @@ server.registerTool('query_issues', {
308
297
  content: [{ type: 'text', text: 'No issues found matching your criteria.' }],
309
298
  };
310
299
  }
311
- // Format results
312
300
  const lines = result.rows.map((row) => {
313
301
  const typed = row;
314
302
  if (typed.issue_key) {
315
303
  return `${typed.issue_key} [${str(typed.status) || '?'}] ${str(typed.summary)} (${str(typed.assignee) || 'unassigned'})`;
316
304
  }
317
- // For raw SQL, just stringify the row
318
305
  return JSON.stringify(row);
319
306
  });
320
307
  return {
@@ -335,7 +322,6 @@ server.registerTool('query_issues', {
335
322
  };
336
323
  }
337
324
  });
338
- // ─── Tool: get_issue ──────────────────────────────────────────────────────────
339
325
  server.registerTool('get_issue', {
340
326
  description: 'Get full details of a specific issue by key, including description, comments, changelogs, and all custom fields.',
341
327
  inputSchema: {
@@ -351,7 +337,6 @@ server.registerTool('get_issue', {
351
337
  }
352
338
  const { storage } = await createAdapters(ws.root);
353
339
  try {
354
- // Get issue
355
340
  const issueResult = await storage.query(`SELECT * FROM issues WHERE issue_key = $1`, [issueKey.toUpperCase()]);
356
341
  if (issueResult.rows.length === 0) {
357
342
  await storage.close();
@@ -361,13 +346,10 @@ server.registerTool('get_issue', {
361
346
  };
362
347
  }
363
348
  const issue = issueResult.rows[0];
364
- // Get comments
365
349
  const commentsResult = await storage.query(`SELECT author, body, created FROM issue_comments WHERE issue_key = $1 ORDER BY created`, [issueKey.toUpperCase()]);
366
- // Get changelogs
367
350
  const changelogsResult = await storage.query(`SELECT author, field, from_value, to_value, changed_at
368
351
  FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at DESC LIMIT 20`, [issueKey.toUpperCase()]);
369
352
  await storage.close();
370
- // Format output
371
353
  const sections = [];
372
354
  sections.push(`# ${str(issue.issue_key)}: ${str(issue.summary)}`);
373
355
  sections.push('');
@@ -424,7 +406,6 @@ server.registerTool('get_issue', {
424
406
  };
425
407
  }
426
408
  });
427
- // ─── Tool: issue_stats ────────────────────────────────────────────────────────
428
409
  server.registerTool('issue_stats', {
429
410
  description: 'Get aggregate statistics about issues in the database — counts by status, type, project, assignee. Useful for project health overview.',
430
411
  inputSchema: {
@@ -440,7 +421,6 @@ server.registerTool('issue_stats', {
440
421
  }
441
422
  const { storage } = await createAdapters(ws.root);
442
423
  try {
443
- // Use parameterized query to prevent SQL injection
444
424
  const filterClause = project ? `WHERE project_key = $1` : '';
445
425
  const filterParams = project ? [project.toUpperCase()] : [];
446
426
  const [total, byStatus, byType, byProject, byAssignee] = await Promise.all([
@@ -599,7 +579,6 @@ server.registerTool('query_commits', {
599
579
  };
600
580
  }
601
581
  });
602
- // ─── Tool: issue_commits ──────────────────────────────────────────────────────
603
582
  server.registerTool('issue_commits', {
604
583
  description: 'Cross-reference: find all Git commits that mention a Jira issue key. Shows what code was actually changed for a ticket.',
605
584
  inputSchema: {
@@ -665,7 +644,6 @@ server.registerTool('issue_commits', {
665
644
  };
666
645
  }
667
646
  });
668
- // ─── Tool: commit_stats ───────────────────────────────────────────────────────
669
647
  server.registerTool('commit_stats', {
670
648
  description: 'Aggregate statistics about Git commits — total count, top authors, most changed files, commits per day.',
671
649
  inputSchema: {
@@ -838,7 +816,6 @@ server.registerTool('query_prs', {
838
816
  };
839
817
  }
840
818
  });
841
- // ─── Tool: issue_prs ──────────────────────────────────────────────────────────
842
819
  server.registerTool('issue_prs', {
843
820
  description: 'Cross-reference: find all GitHub pull requests that mention a Jira issue key. Shows which PRs implemented a ticket.',
844
821
  inputSchema: {
@@ -917,7 +894,6 @@ server.registerTool('issue_timeline', {
917
894
  const { storage } = await createAdapters(ws.root);
918
895
  const key = issueKey.toUpperCase();
919
896
  try {
920
- // 5 parallel queries
921
897
  const [issueResult, changelogsResult, commitsResult, prsResult, commitFilesResult] = await Promise.all([
922
898
  storage.query(`SELECT issue_key, summary, status, issue_type, assignee, reporter, created, updated, resolved FROM issues WHERE issue_key = $1`, [key]),
923
899
  storage.query(`SELECT author, field, from_value, to_value, changed_at FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at`, [key]),
@@ -939,7 +915,6 @@ server.registerTool('issue_timeline', {
939
915
  };
940
916
  }
941
917
  const issue = issueResult.rows[0];
942
- // Fetch reviews for found PRs
943
918
  const prRows = prsResult.rows;
944
919
  const reviewsByPr = new Map();
945
920
  for (const pr of prRows) {
@@ -947,20 +922,16 @@ server.registerTool('issue_timeline', {
947
922
  reviewsByPr.set(pr.number, reviewsResult.rows);
948
923
  }
949
924
  await storage.close();
950
- // Build file map for commits
951
925
  const filesByCommit = new Map();
952
926
  for (const f of commitFilesResult.rows) {
953
927
  const arr = filesByCommit.get(f.commit_hash) ?? [];
954
928
  arr.push(f);
955
929
  filesByCommit.set(f.commit_hash, arr);
956
930
  }
957
- // Build timeline events
958
931
  const events = [];
959
- // Issue created
960
932
  if (issue.created) {
961
933
  events.push({ date: issue.created, type: 'created', text: 'Issue created' });
962
934
  }
963
- // Changelogs
964
935
  for (const raw of changelogsResult.rows) {
965
936
  const ch = raw;
966
937
  if (ch.changed_at) {
@@ -971,7 +942,6 @@ server.registerTool('issue_timeline', {
971
942
  });
972
943
  }
973
944
  }
974
- // Commits
975
945
  for (const raw of commitsResult.rows) {
976
946
  const c = raw;
977
947
  if (c.committed_at) {
@@ -983,7 +953,6 @@ server.registerTool('issue_timeline', {
983
953
  });
984
954
  }
985
955
  }
986
- // PRs opened + merged
987
956
  for (const pr of prRows) {
988
957
  if (pr.created_at) {
989
958
  events.push({
@@ -992,7 +961,6 @@ server.registerTool('issue_timeline', {
992
961
  text: `PR #${String(pr.number)} opened — "${str(pr.title)}" (${str(pr.author)})`,
993
962
  });
994
963
  }
995
- // Reviews
996
964
  const reviews = reviewsByPr.get(pr.number) ?? [];
997
965
  for (const r of reviews) {
998
966
  if (r.submitted_at) {
@@ -1011,22 +979,18 @@ server.registerTool('issue_timeline', {
1011
979
  });
1012
980
  }
1013
981
  }
1014
- // Sort chronologically
1015
982
  events.sort((a, b) => a.date.localeCompare(b.date));
1016
- // Format output
1017
983
  const sections = [];
1018
984
  sections.push(`=== ISSUE: ${key} ===`);
1019
985
  sections.push(`Summary: ${str(issue.summary)}`);
1020
986
  sections.push(`Status: ${str(issue.status)} | Type: ${str(issue.issue_type)} | Assignee: ${str(issue.assignee)}`);
1021
987
  sections.push(`Created: ${str(issue.created ?? '').substring(0, 10)} | Resolved: ${str(issue.resolved ?? '').substring(0, 10) || 'n/a'}`);
1022
988
  sections.push('');
1023
- // Timeline
1024
989
  sections.push(`--- TIMELINE (${String(events.length)} events) ---`);
1025
990
  for (const ev of events) {
1026
991
  sections.push(`[${ev.date.substring(0, 10)}] ${ev.text}`);
1027
992
  }
1028
993
  sections.push('');
1029
- // Commits summary
1030
994
  const commitRows = commitsResult.rows;
1031
995
  if (commitRows.length > 0) {
1032
996
  sections.push(`--- COMMITS (${String(commitRows.length)}) ---`);
@@ -1039,7 +1003,6 @@ server.registerTool('issue_timeline', {
1039
1003
  }
1040
1004
  sections.push('');
1041
1005
  }
1042
- // PRs summary
1043
1006
  if (prRows.length > 0) {
1044
1007
  sections.push(`--- PULL REQUESTS (${String(prRows.length)}) ---`);
1045
1008
  for (const pr of prRows) {
@@ -1061,7 +1024,6 @@ server.registerTool('issue_timeline', {
1061
1024
  };
1062
1025
  }
1063
1026
  });
1064
- // ─── Tool: query_releases ─────────────────────────────────────────────────────
1065
1027
  server.registerTool('query_releases', {
1066
1028
  description: 'List GitHub releases for the repository. Useful for understanding release cadence and what was shipped.',
1067
1029
  inputSchema: {
@@ -1126,7 +1088,6 @@ server.registerTool('query_releases', {
1126
1088
  };
1127
1089
  }
1128
1090
  });
1129
- // ─── Tool: semantic_search ────────────────────────────────────────────────────
1130
1091
  server.registerTool('semantic_search', {
1131
1092
  description: 'Semantic vector similarity search across issues. Uses AI embeddings to find issues by meaning, not just keywords. Requires embeddings generated first ("argustack embed").',
1132
1093
  inputSchema: {
@@ -1208,14 +1169,44 @@ server.registerTool('semantic_search', {
1208
1169
  };
1209
1170
  }
1210
1171
  });
1172
+ function calculateFamiliarityFactor(familiarityRows, taskComponents) {
1173
+ if (!taskComponents || taskComponents.length === 0 || familiarityRows.length === 0) {
1174
+ return { factor: 1.0, explanation: 'No component data' };
1175
+ }
1176
+ const matching = familiarityRows.filter((f) => taskComponents.some((c) => c.toLowerCase() === f.component.toLowerCase()));
1177
+ if (matching.length === 0) {
1178
+ return { factor: 1.0, explanation: 'No history in these components' };
1179
+ }
1180
+ const totalResolved = matching.reduce((sum, c) => sum + c.resolved_count, 0);
1181
+ const factor = Math.max(0.6, Math.min(1.0, 1.0 - 0.08 * totalResolved));
1182
+ const compNames = matching.map((c) => `${c.component}(${String(c.resolved_count)})`).join(', ');
1183
+ return { factor, explanation: `${String(totalResolved)} resolved in ${compNames} — ×${factor.toFixed(2)}` };
1184
+ }
1185
+ function calculateBaseHours(metrics) {
1186
+ if (metrics.length === 0) {
1187
+ return { hours: 0, method: 'no data' };
1188
+ }
1189
+ const sorted = [...metrics].sort((a, b) => a.hours - b.hours);
1190
+ const trimCount = metrics.length > 5 ? Math.max(1, Math.floor(metrics.length * 0.1)) : 0;
1191
+ const trimmed = sorted.slice(trimCount, sorted.length - trimCount || undefined);
1192
+ const totalWeight = trimmed.reduce((sum, m) => sum + m.weight, 0);
1193
+ if (totalWeight === 0) {
1194
+ const simple = trimmed.reduce((sum, m) => sum + m.hours, 0) / trimmed.length;
1195
+ return { hours: simple, method: `simple average (${String(trimmed.length)}/${String(metrics.length)} tasks)` };
1196
+ }
1197
+ const weighted = trimmed.reduce((sum, m) => sum + m.hours * m.weight, 0) / totalWeight;
1198
+ return { hours: weighted, method: `weighted trimmed mean (${String(trimmed.length)}/${String(metrics.length)} tasks)` };
1199
+ }
1211
1200
  server.registerTool('estimate', {
1212
- description: 'Predict effort for a new task based on historical data. Finds similar completed tasks, analyzes cycle times, worklogs per developer, bug aftermath, estimate accuracy. Two key inputs: WHAT (task description) and WHO (developer).',
1201
+ description: 'Predict how long a task will take for a specific developer. Returns TWO predictions: "without bugs" (pure development time) and "with bugs" (real cost including bug aftermath). Based on similar completed tasks, personal coefficient from full history, and component familiarity. Assignee is required always specify who will do the task.',
1213
1202
  inputSchema: {
1214
1203
  description: z.string().describe('Description of the new task (e.g. "Stripe payment integration with subscriptions")'),
1215
- assignee: z.string().optional().describe('Developer name to predict for. If omitted, shows all developers who worked on similar tasks'),
1204
+ assignee: z.string().describe('Developer name to predict for (e.g. "Dmitry Kislitsyn")'),
1205
+ issue_type: z.string().optional().describe('Issue type: Bug, Task, Story — finds same-type analogs and uses type-specific coefficients'),
1206
+ components: z.array(z.string()).optional().describe('Component names (e.g. ["LOC Draws", "Export"]) — finds tasks in same area and calculates familiarity'),
1216
1207
  limit: z.number().optional().describe('Number of similar tasks to analyze (default: 10)'),
1217
1208
  },
1218
- }, async ({ description, assignee, limit }) => {
1209
+ }, async ({ description, assignee, issue_type: issueTypeInput, components, limit }) => {
1219
1210
  const ws = loadWorkspace();
1220
1211
  if (!ws.ok) {
1221
1212
  return {
@@ -1226,15 +1217,53 @@ server.registerTool('estimate', {
1226
1217
  const { storage } = await createAdapters(ws.root);
1227
1218
  try {
1228
1219
  const maxResults = limit ?? 10;
1229
- // 1. Find similar DONE issues via full-text search + status_category
1230
- const similarResult = await storage.query(`SELECT issue_key, summary, issue_type, status, assignee, created, resolved,
1231
- parent_key, story_points,
1232
- ts_rank(search_vector, plainto_tsquery('english', $1)) as rank
1233
- FROM issues
1234
- WHERE search_vector @@ plainto_tsquery('english', $1)
1235
- AND status_category = 'Done'
1236
- ORDER BY rank DESC
1237
- LIMIT $2`, [description, maxResults]);
1220
+ const issueType = issueTypeInput ?? null;
1221
+ const comps = components && components.length > 0 ? components : null;
1222
+ const similarResult = await storage.query(`WITH text_matches AS (
1223
+ SELECT issue_key, summary, issue_type, status, assignee, created, resolved,
1224
+ parent_key, story_points, components, labels, original_estimate, time_spent,
1225
+ ts_rank(search_vector, plainto_tsquery('english', $1)) as text_rank
1226
+ FROM issues
1227
+ WHERE search_vector @@ plainto_tsquery('english', $1)
1228
+ AND status_category = 'Done'
1229
+ ),
1230
+ scored AS (
1231
+ SELECT *,
1232
+ CASE WHEN $3::text IS NOT NULL AND issue_type = $3 THEN 1.0 ELSE 0.0 END as type_match,
1233
+ CASE WHEN $4::text[] IS NOT NULL AND array_length($4::text[], 1) > 0
1234
+ THEN COALESCE((
1235
+ SELECT COUNT(*)::float / array_length($4::text[], 1)
1236
+ FROM unnest($4::text[]) q_comp
1237
+ WHERE q_comp = ANY(components)
1238
+ ), 0)
1239
+ ELSE 0.0
1240
+ END as component_overlap,
1241
+ CASE WHEN resolved IS NOT NULL
1242
+ THEN 1.0 / (1.0 + EXTRACT(EPOCH FROM (NOW() - resolved)) / (86400.0 * 365))
1243
+ ELSE 0.5
1244
+ END as temporal_weight,
1245
+ (
1246
+ LEAST(text_rank * 10, 1.0) * 0.3
1247
+ + CASE WHEN $3::text IS NOT NULL AND issue_type = $3 THEN 0.25 ELSE 0.0 END
1248
+ + CASE WHEN $4::text[] IS NOT NULL AND array_length($4::text[], 1) > 0
1249
+ THEN COALESCE((
1250
+ SELECT COUNT(*)::float / array_length($4::text[], 1)
1251
+ FROM unnest($4::text[]) q_comp
1252
+ WHERE q_comp = ANY(components)
1253
+ ), 0) * 0.35
1254
+ ELSE 0.0
1255
+ END
1256
+ + (CASE WHEN resolved IS NOT NULL
1257
+ THEN 1.0 / (1.0 + EXTRACT(EPOCH FROM (NOW() - resolved)) / (86400.0 * 365))
1258
+ ELSE 0.5
1259
+ END) * 0.1
1260
+ ) as composite_score
1261
+ FROM text_matches
1262
+ )
1263
+ SELECT *, composite_score as rank
1264
+ FROM scored
1265
+ ORDER BY composite_score DESC
1266
+ LIMIT $2`, [description, maxResults, issueType, comps]);
1238
1267
  const similar = similarResult.rows;
1239
1268
  if (similar.length === 0) {
1240
1269
  await storage.close();
@@ -1244,13 +1273,11 @@ server.registerTool('estimate', {
1244
1273
  }
1245
1274
  const issueKeys = similar.map((r) => r.issue_key);
1246
1275
  const keysParam = issueKeys.map((_, i) => `$${String(i + 1)}`).join(',');
1247
- // 2. Worklogs per developer per issue
1248
1276
  const worklogsResult = await storage.query(`SELECT issue_key, author, SUM(time_spent_seconds) as total_seconds
1249
1277
  FROM issue_worklogs
1250
1278
  WHERE issue_key IN (${keysParam})
1251
1279
  GROUP BY issue_key, author`, issueKeys);
1252
1280
  const worklogs = worklogsResult.rows;
1253
- // 3. Real developer — first assignee from changelogs (the person who started working)
1254
1281
  const devChangelogResult = await storage.query(`SELECT DISTINCT ON (issue_key) issue_key, to_value as dev_assignee
1255
1282
  FROM issue_changelogs
1256
1283
  WHERE issue_key IN (${keysParam})
@@ -1263,14 +1290,13 @@ server.registerTool('estimate', {
1263
1290
  for (const d of devChangelogs) {
1264
1291
  realDevMap.set(d.issue_key, d.dev_assignee);
1265
1292
  }
1266
- // 4. Commits linked to these issues — with real coding time (first → last commit)
1267
1293
  const commitsResult = await storage.query(`SELECT r.issue_key,
1268
1294
  COUNT(*) as commits,
1269
1295
  STRING_AGG(DISTINCT c.author, ', ') as authors,
1270
1296
  SUM(cf_agg.additions) as total_additions,
1271
1297
  SUM(cf_agg.deletions) as total_deletions,
1272
- MIN(c.author_date) as first_commit,
1273
- MAX(c.author_date) as last_commit
1298
+ MIN(c.committed_at) as first_commit,
1299
+ MAX(c.committed_at) as last_commit
1274
1300
  FROM commit_issue_refs r
1275
1301
  JOIN commits c ON r.commit_hash = c.hash
1276
1302
  LEFT JOIN (
@@ -1280,35 +1306,117 @@ server.registerTool('estimate', {
1280
1306
  WHERE r.issue_key IN (${keysParam})
1281
1307
  GROUP BY r.issue_key`, issueKeys);
1282
1308
  const commitData = commitsResult.rows;
1283
- // 5. Related issues children (subtasks, bugs) + linked issues
1284
- const childrenResult = await storage.query(`SELECT i.parent_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created
1309
+ const notInParam = issueKeys.map((_, i) => `$${String(i + 1 + issueKeys.length)}`).join(',');
1310
+ const childrenResult = await storage.query(`SELECT i.parent_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created, i.time_spent as bug_time_spent
1285
1311
  FROM issues i
1286
1312
  WHERE i.parent_key IN (${keysParam})
1287
- AND i.issue_key NOT IN (${keysParam})`, [...issueKeys, ...issueKeys]);
1288
- const linkedResult = await storage.query(`SELECT il.source_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created
1313
+ AND i.issue_key NOT IN (${notInParam})`, [...issueKeys, ...issueKeys]);
1314
+ const linkedResult = await storage.query(`SELECT il.source_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created, i.time_spent as bug_time_spent
1289
1315
  FROM issue_links il
1290
1316
  JOIN issues i ON i.issue_key = il.target_key
1291
1317
  WHERE il.source_key IN (${keysParam})
1292
- AND i.issue_key NOT IN (${keysParam})`, [...issueKeys, ...issueKeys]);
1318
+ AND i.issue_key NOT IN (${notInParam})`, [...issueKeys, ...issueKeys]);
1293
1319
  const bugs = [
1294
1320
  ...childrenResult.rows,
1295
1321
  ...linkedResult.rows,
1296
1322
  ];
1297
- // 6. Original estimates from raw_json
1298
- const rawEstimates = await storage.query(`SELECT issue_key,
1299
- raw_json->'fields'->>'timeoriginalestimate' as original_estimate,
1300
- raw_json->'fields'->>'timespent' as time_spent,
1301
- raw_json->'fields'->>'aggregatetimespent' as aggregate_time
1323
+ const rawEstimates = await storage.query(`SELECT issue_key, original_estimate, time_spent
1302
1324
  FROM issues
1303
1325
  WHERE issue_key IN (${keysParam})`, issueKeys);
1304
1326
  const estimates = rawEstimates.rows;
1327
+ let familiarity = { factor: 1.0, explanation: 'No component data' };
1328
+ if (assignee && comps) {
1329
+ const familiarityResult = await storage.query(`SELECT
1330
+ unnest(components) as component,
1331
+ COUNT(DISTINCT issue_key) as resolved_count,
1332
+ AVG(time_spent::float / 3600) as avg_time_hours,
1333
+ MAX(resolved)::text as last_resolved
1334
+ FROM issues
1335
+ WHERE assignee ILIKE $1
1336
+ AND status_category = 'Done'
1337
+ AND time_spent IS NOT NULL AND time_spent > 0
1338
+ AND components IS NOT NULL AND array_length(components, 1) > 0
1339
+ GROUP BY unnest(components)
1340
+ ORDER BY resolved_count DESC`, [`%${assignee}%`]);
1341
+ const familiarityRows = familiarityResult.rows;
1342
+ familiarity = calculateFamiliarityFactor(familiarityRows, comps);
1343
+ }
1344
+ const coefficientResult = await storage.query(`WITH base AS (
1345
+ SELECT
1346
+ parent.assignee,
1347
+ parent.issue_type,
1348
+ parent.issue_key,
1349
+ parent.original_estimate,
1350
+ parent.time_spent,
1351
+ COALESCE(bug_agg.bug_time, 0) as bug_time
1352
+ FROM issues parent
1353
+ LEFT JOIN (
1354
+ SELECT parent_ref, SUM(bug_ts) as bug_time
1355
+ FROM (
1356
+ SELECT i.parent_key as parent_ref, i.time_spent as bug_ts
1357
+ FROM issues i
1358
+ WHERE i.issue_type IN ('Bug', 'Sub-bug')
1359
+ AND i.time_spent IS NOT NULL AND i.time_spent > 0
1360
+ UNION ALL
1361
+ SELECT il.source_key as parent_ref, i.time_spent as bug_ts
1362
+ FROM issue_links il
1363
+ JOIN issues i ON i.issue_key = il.target_key
1364
+ WHERE i.issue_type IN ('Bug', 'Sub-bug')
1365
+ AND i.time_spent IS NOT NULL AND i.time_spent > 0
1366
+ ) bugs
1367
+ GROUP BY parent_ref
1368
+ ) bug_agg ON bug_agg.parent_ref = parent.issue_key
1369
+ WHERE parent.status_category = 'Done'
1370
+ AND parent.original_estimate IS NOT NULL AND parent.original_estimate > 0
1371
+ AND parent.time_spent IS NOT NULL AND parent.time_spent > 0
1372
+ AND parent.issue_type NOT IN ('Bug', 'Sub-bug')
1373
+ AND CAST(parent.time_spent AS FLOAT) / parent.original_estimate < 5.0
1374
+ ),
1375
+ context_coeffs AS (
1376
+ SELECT
1377
+ assignee,
1378
+ COUNT(DISTINCT issue_key)::text as task_count,
1379
+ PERCENTILE_CONT(0.5) WITHIN GROUP (
1380
+ ORDER BY CAST(time_spent AS FLOAT) / original_estimate
1381
+ ) as coeff_no_bugs,
1382
+ PERCENTILE_CONT(0.5) WITHIN GROUP (
1383
+ ORDER BY CAST(time_spent + bug_time AS FLOAT) / original_estimate
1384
+ ) as coeff_with_bugs,
1385
+ AVG(CAST(bug_time AS FLOAT) / NULLIF(time_spent, 0)) as bug_ratio,
1386
+ COALESCE($1, 'all types') as context_label
1387
+ FROM base
1388
+ WHERE ($1::text IS NULL OR issue_type = $1)
1389
+ GROUP BY assignee
1390
+ HAVING COUNT(DISTINCT issue_key) >= 3
1391
+ ),
1392
+ global_coeffs AS (
1393
+ SELECT
1394
+ assignee,
1395
+ COUNT(DISTINCT issue_key)::text as task_count,
1396
+ PERCENTILE_CONT(0.5) WITHIN GROUP (
1397
+ ORDER BY CAST(time_spent AS FLOAT) / original_estimate
1398
+ ) as coeff_no_bugs,
1399
+ PERCENTILE_CONT(0.5) WITHIN GROUP (
1400
+ ORDER BY CAST(time_spent + bug_time AS FLOAT) / original_estimate
1401
+ ) as coeff_with_bugs,
1402
+ AVG(CAST(bug_time AS FLOAT) / NULLIF(time_spent, 0)) as bug_ratio,
1403
+ 'all types (fallback)' as context_label
1404
+ FROM base
1405
+ GROUP BY assignee
1406
+ HAVING COUNT(DISTINCT issue_key) >= 3
1407
+ )
1408
+ SELECT * FROM context_coeffs
1409
+ UNION ALL
1410
+ SELECT * FROM global_coeffs
1411
+ WHERE assignee NOT IN (SELECT assignee FROM context_coeffs)`, [issueType]);
1412
+ const coefficients = coefficientResult.rows;
1305
1413
  await storage.close();
1306
- // ─── Build report ───
1307
1414
  const sections = [];
1308
1415
  sections.push(`# Estimate Prediction`);
1309
- sections.push(`Query: "${description}"${assignee ? ` | Developer: ${assignee}` : ''}`);
1310
- sections.push(`Based on ${String(similar.length)} similar completed tasks\n`);
1311
- // Index data by issue_key
1416
+ const metaParts = [assignee ? `Developer: ${assignee}` : '', issueType ? `Type: ${issueType}` : '', comps ? `Components: ${comps.join(', ')}` : ''].filter(Boolean);
1417
+ sections.push(`Query: "${description}"${metaParts.length > 0 ? ` | ${metaParts.join(' | ')}` : ''}`);
1418
+ sections.push(`Based on ${String(similar.length)} similar completed tasks`);
1419
+ sections.push(`Scoring: text 30% + type ${issueType ? '25%' : '0%'} + component ${comps ? '35%' : '0%'} + recency 10%\n`);
1312
1420
  const worklogMap = new Map();
1313
1421
  for (const w of worklogs) {
1314
1422
  const arr = worklogMap.get(w.issue_key) ?? [];
@@ -1329,11 +1437,9 @@ server.registerTool('estimate', {
1329
1437
  arr.push(b);
1330
1438
  bugMap.set(b.related_to, arr);
1331
1439
  }
1332
- // ─── Per-issue breakdown ───
1333
1440
  sections.push('## Similar Tasks\n');
1334
1441
  let totalCycleHours = 0;
1335
1442
  let totalCodingHours = 0;
1336
- let totalWorklogHours = 0;
1337
1443
  let totalBugs = 0;
1338
1444
  let validCycleCount = 0;
1339
1445
  let validCodingCount = 0;
@@ -1350,7 +1456,6 @@ server.registerTool('estimate', {
1350
1456
  const issueCommits = commitMap.get(issue.issue_key);
1351
1457
  const issueBugs = bugMap.get(issue.issue_key) ?? [];
1352
1458
  const issueEstimate = estimateMap.get(issue.issue_key);
1353
- // Real coding time from commits
1354
1459
  const codingHours = (issueCommits?.first_commit && issueCommits.last_commit)
1355
1460
  ? (new Date(issueCommits.last_commit).getTime() - new Date(issueCommits.first_commit).getTime()) / 3600000
1356
1461
  : null;
@@ -1358,10 +1463,7 @@ server.registerTool('estimate', {
1358
1463
  totalCodingHours += codingHours;
1359
1464
  validCodingCount++;
1360
1465
  }
1361
- const worklogHours = issueWorklogs.reduce((sum, w) => sum + Number(w.total_seconds), 0) / 3600;
1362
- totalWorklogHours += worklogHours;
1363
1466
  totalBugs += issueBugs.length;
1364
- // Track developer stats — priority: changelog assignee → worklogs → commits → current assignee
1365
1467
  const realDev = realDevMap.get(issue.issue_key);
1366
1468
  const devName = realDev ?? (issueWorklogs.length > 0 ? issueWorklogs[0]?.author : null) ?? issueCommits?.authors ?? issue.assignee ?? 'unknown';
1367
1469
  if (devName) {
@@ -1373,14 +1475,29 @@ server.registerTool('estimate', {
1373
1475
  stats.commits += Number(issueCommits?.commits ?? 0);
1374
1476
  developerStats.set(devName, stats);
1375
1477
  }
1376
- const originalEst = issueEstimate?.original_estimate ? `${String(Math.round(Number(issueEstimate.original_estimate) / 3600))}h est` : '';
1377
- const actualTime = issueEstimate?.time_spent ? `${String(Math.round(Number(issueEstimate.time_spent) / 3600))}h actual` : '';
1478
+ const estH = issueEstimate?.original_estimate ? issueEstimate.original_estimate / 3600 : null;
1479
+ const actualH = issueEstimate?.time_spent ? issueEstimate.time_spent / 3600 : null;
1480
+ const bugTimeH = issueBugs
1481
+ .filter((b) => b.bug_time_spent !== null)
1482
+ .reduce((sum, b) => sum + (b.bug_time_spent ?? 0), 0) / 3600;
1483
+ const realCostH = (actualH ?? 0) + bugTimeH;
1484
+ const taskCoeff = estH && estH > 0 && actualH ? actualH / estH : null;
1485
+ const taskCoeffBugs = estH && estH > 0 ? realCostH / estH : null;
1378
1486
  const cycleStr = cycleHours !== null ? `${cycleHours.toFixed(1)}h cycle` : 'open';
1379
1487
  const codingStr = codingHours !== null && codingHours > 0 ? ` | ${codingHours.toFixed(1)}h coding` : '';
1488
+ const scoreStr = `score: ${issue.composite_score.toFixed(2)}`;
1489
+ const matchParts = [issue.type_match > 0 ? 'type' : '', issue.component_overlap > 0 ? `comp:${(issue.component_overlap * 100).toFixed(0)}%` : ''].filter(Boolean);
1490
+ const matchStr = matchParts.length > 0 ? ` [${matchParts.join(', ')}]` : '';
1380
1491
  sections.push(`### ${issue.issue_key}: ${issue.summary}`);
1381
- sections.push(`Type: ${issue.issue_type} | Dev: ${devName} | ${cycleStr}${codingStr}`);
1382
- if (originalEst || actualTime) {
1383
- sections.push(`Estimate: ${[originalEst, actualTime].filter(Boolean).join(' ')}`);
1492
+ sections.push(`Type: ${issue.issue_type} | Dev: ${devName} | ${cycleStr}${codingStr} | ${scoreStr}${matchStr}`);
1493
+ if (estH !== null || actualH !== null) {
1494
+ const estStr = estH !== null ? `${Math.round(estH)}h est` : '';
1495
+ const actStr = actualH !== null ? `${Math.round(actualH)}h actual` : '';
1496
+ const coeffStr = taskCoeff !== null ? ` (×${taskCoeff.toFixed(2)})` : '';
1497
+ sections.push(`Estimate: ${[estStr, actStr].filter(Boolean).join(' → ')}${coeffStr}`);
1498
+ }
1499
+ if (bugTimeH > 0) {
1500
+ sections.push(`Bug aftermath: ${bugTimeH.toFixed(1)}h → real cost: ${realCostH.toFixed(1)}h (×${taskCoeffBugs?.toFixed(2) ?? '?'})`);
1384
1501
  }
1385
1502
  if (issueCommits) {
1386
1503
  sections.push(`Code: ${issueCommits.commits} commits, +${issueCommits.total_additions}/-${issueCommits.total_deletions} lines (${issueCommits.authors})`);
@@ -1390,28 +1507,44 @@ server.registerTool('estimate', {
1390
1507
  sections.push(`Worklogs:\n${wlLines.join('\n')}`);
1391
1508
  }
1392
1509
  if (issueBugs.length > 0) {
1393
- const bugLines = issueBugs.map((b) => ` ${b.bug_key} [${b.issue_type}] ${b.summary}`);
1510
+ const bugLines = issueBugs.map((b) => {
1511
+ const bTimeStr = b.bug_time_spent ? ` [${(b.bug_time_spent / 3600).toFixed(1)}h]` : '';
1512
+ return ` ${b.bug_key} [${b.issue_type}]${bTimeStr} ${b.summary}`;
1513
+ });
1394
1514
  sections.push(`Related issues (${String(issueBugs.length)}):\n${bugLines.join('\n')}`);
1395
1515
  }
1396
1516
  sections.push('');
1397
1517
  }
1398
- // ─── Aggregate prediction ───
1399
- sections.push('## Prediction\n');
1518
+ const taskMetrics = [];
1519
+ for (const issue of similar) {
1520
+ const issueWorklogs = worklogMap.get(issue.issue_key) ?? [];
1521
+ const issueCommits = commitMap.get(issue.issue_key);
1522
+ const issueEstimate = estimateMap.get(issue.issue_key);
1523
+ const codingHours = (issueCommits?.first_commit && issueCommits.last_commit)
1524
+ ? (new Date(issueCommits.last_commit).getTime() - new Date(issueCommits.first_commit).getTime()) / 3600000
1525
+ : null;
1526
+ const worklogHours = issueWorklogs.reduce((sum, w) => sum + Number(w.total_seconds), 0) / 3600;
1527
+ const actualH = issueEstimate?.time_spent ? issueEstimate.time_spent / 3600 : null;
1528
+ const cycleH = issue.resolved
1529
+ ? (new Date(issue.resolved).getTime() - new Date(issue.created).getTime()) / 3600000
1530
+ : null;
1531
+ const hours = actualH ?? (codingHours && codingHours > 0 ? codingHours : null) ?? (worklogHours > 0 ? worklogHours : null) ?? cycleH;
1532
+ if (hours !== null && hours > 0) {
1533
+ taskMetrics.push({ issueKey: issue.issue_key, hours, weight: issue.temporal_weight });
1534
+ }
1535
+ }
1536
+ const base = calculateBaseHours(taskMetrics);
1537
+ sections.push('## Similar Tasks Summary\n');
1400
1538
  const avgCycle = validCycleCount > 0 ? totalCycleHours / validCycleCount : 0;
1401
1539
  const avgCoding = validCodingCount > 0 ? totalCodingHours / validCodingCount : 0;
1402
1540
  const avgBugs = similar.length > 0 ? totalBugs / similar.length : 0;
1403
- // Best effort time: coding > worklogs > cycle
1404
- const bestTimeSource = avgCoding > 0 ? { label: 'coding time (commits)', hours: avgCoding }
1405
- : totalWorklogHours > 0 ? { label: 'logged time (worklogs)', hours: totalWorklogHours / similar.length }
1406
- : { label: 'cycle time (created→resolved)', hours: avgCycle };
1407
- sections.push(`Average ${bestTimeSource.label}: ${bestTimeSource.hours.toFixed(1)}h (${(bestTimeSource.hours / 8).toFixed(1)} working days)`);
1541
+ sections.push(`Base hours: ${base.hours.toFixed(1)}h (${base.method})`);
1408
1542
  if (avgCoding > 0 && avgCycle > 0) {
1409
- sections.push(`Cycle time (includes waiting/review): ${avgCycle.toFixed(1)}h — coding was ${((avgCoding / avgCycle) * 100).toFixed(0)}% of it`);
1543
+ sections.push(`Cycle time: ${avgCycle.toFixed(1)}h — coding was ${((avgCoding / avgCycle) * 100).toFixed(0)}% of it`);
1410
1544
  }
1411
1545
  sections.push(`Bug rate: ${avgBugs.toFixed(1)} bugs per task`);
1412
- // ─── Developer breakdown ───
1413
1546
  if (developerStats.size > 0) {
1414
- sections.push('\n## Developer Profiles\n');
1547
+ sections.push('\n## Developer Profiles (similar tasks)\n');
1415
1548
  for (const [dev, stats] of developerStats) {
1416
1549
  if (assignee && !dev.toLowerCase().includes(assignee.toLowerCase())) {
1417
1550
  continue;
@@ -1423,13 +1556,51 @@ server.registerTool('estimate', {
1423
1556
  sections.push(`**${dev}**: ${String(stats.tasks)} similar tasks, avg ${devBestHours.toFixed(1)}h (${(devBestHours / 8).toFixed(1)}d), ${bugRate.toFixed(1)} bugs/task, ${String(stats.commits)} commits`);
1424
1557
  }
1425
1558
  }
1426
- // ─── Final estimate ───
1427
- sections.push('\n## Recommended Estimate\n');
1428
- const bufferMultiplier = 1.3;
1429
- const predictedHours = bestTimeSource.hours * bufferMultiplier;
1430
- sections.push(`Base: ${bestTimeSource.hours.toFixed(0)}h + 30% buffer = **${predictedHours.toFixed(0)}h (${(predictedHours / 8).toFixed(1)} working days)**`);
1559
+ if (familiarity.factor < 1.0) {
1560
+ sections.push(`\n## Developer Familiarity\n`);
1561
+ sections.push(`${assignee}: ${familiarity.explanation}`);
1562
+ }
1563
+ if (coefficients.length > 0) {
1564
+ sections.push('\n## Developer Coefficients\n');
1565
+ const relevantCoeffs = assignee
1566
+ ? coefficients.filter((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()))
1567
+ : coefficients;
1568
+ for (const c of relevantCoeffs) {
1569
+ const noBugs = Number(c.coeff_no_bugs).toFixed(2);
1570
+ const withBugs = Number(c.coeff_with_bugs).toFixed(2);
1571
+ const ratio = (Number(c.bug_ratio) * 100).toFixed(0);
1572
+ sections.push(`**${c.assignee}**: ×${noBugs} without bugs, ×${withBugs} with bugs (${c.task_count} tasks, bug overhead ${ratio}%, ${c.context_label}, median, outliers excluded)`);
1573
+ }
1574
+ }
1575
+ sections.push('\n## Prediction\n');
1576
+ const contextCoeffs = coefficients.filter((c) => c.context_label !== 'all types (fallback)');
1577
+ const globalCoeffs = coefficients.filter((c) => c.context_label === 'all types (fallback)');
1578
+ const baseHours = base.hours;
1579
+ const buildDevPrediction = (dev, label) => {
1580
+ const noBugs = Number(dev.coeff_no_bugs);
1581
+ const withBugs = Number(dev.coeff_with_bugs);
1582
+ const predNoBugs = baseHours * noBugs;
1583
+ const predWithBugs = baseHours * withBugs;
1584
+ const overhead = noBugs > 0 ? ((withBugs - noBugs) / noBugs * 100).toFixed(0) : '0';
1585
+ const lines = [];
1586
+ lines.push(`### ${dev.assignee} ${label}`);
1587
+ lines.push(`Without bugs: ${baseHours.toFixed(1)}h ×${noBugs.toFixed(2)} = **${predNoBugs.toFixed(1)}h** (${(predNoBugs / 8).toFixed(1)}d)`);
1588
+ lines.push(`With bugs: ${baseHours.toFixed(1)}h ×${withBugs.toFixed(2)} = **${predWithBugs.toFixed(1)}h** (${(predWithBugs / 8).toFixed(1)}d) — bug overhead +${overhead}%`);
1589
+ lines.push(`Based on ${dev.task_count} completed tasks, ${dev.context_label}\n`);
1590
+ return lines;
1591
+ };
1592
+ const devCtx = contextCoeffs.find((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()));
1593
+ const devGlob = globalCoeffs.find((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()));
1594
+ const dev = devCtx ?? devGlob;
1595
+ if (dev) {
1596
+ sections.push(...buildDevPrediction(dev, ''));
1597
+ }
1598
+ else {
1599
+ sections.push(`No coefficient data for "${assignee}". Need ≥3 completed tasks with estimates.`);
1600
+ sections.push(`${baseHours.toFixed(1)}h based on similar tasks (no personal coefficient)\n`);
1601
+ }
1431
1602
  if (avgBugs > 0.5) {
1432
- sections.push(`⚠ High bug rate (${avgBugs.toFixed(1)}/task) consider additional buffer`);
1603
+ sections.push(`High bug rate (${avgBugs.toFixed(1)}/task) among similar tasks`);
1433
1604
  }
1434
1605
  return {
1435
1606
  content: [{ type: 'text', text: sections.join('\n') }],
@@ -1443,14 +1614,11 @@ server.registerTool('estimate', {
1443
1614
  };
1444
1615
  }
1445
1616
  });
1446
- // ─── Start ────────────────────────────────────────────────────────────────────
1447
1617
  export async function startMcpServer() {
1448
1618
  const transport = new StdioServerTransport();
1449
1619
  await server.connect(transport);
1450
- // IMPORTANT: use console.error, not console.log — stdout is for JSON-RPC
1451
1620
  console.error('Argustack MCP server running on stdio');
1452
1621
  }
1453
- // Allow direct execution: node dist/mcp/server.js
1454
1622
  const isDirectRun = typeof process !== 'undefined' &&
1455
1623
  process.argv[1] &&
1456
1624
  (process.argv[1].endsWith('/mcp/server.js') || process.argv[1].endsWith('/mcp/server.ts'));