ralphctl 0.1.0 → 0.1.2

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 (130) hide show
  1. package/README.md +58 -24
  2. package/dist/add-HGJCLWED.mjs +14 -0
  3. package/dist/add-MRGCS3US.mjs +14 -0
  4. package/dist/chunk-6PYTKGB5.mjs +316 -0
  5. package/dist/chunk-7TG3EAQ2.mjs +20 -0
  6. package/dist/chunk-EKMZZRWI.mjs +521 -0
  7. package/dist/chunk-JON4GCLR.mjs +59 -0
  8. package/dist/chunk-LOR7QBXX.mjs +3683 -0
  9. package/dist/chunk-MNMQC36F.mjs +556 -0
  10. package/dist/chunk-MRKOFVTM.mjs +537 -0
  11. package/dist/chunk-NTWO2LXB.mjs +52 -0
  12. package/dist/chunk-QBXHAXHI.mjs +562 -0
  13. package/dist/chunk-WGHJI3OI.mjs +214 -0
  14. package/dist/cli.mjs +4245 -0
  15. package/dist/create-MG7E7PLQ.mjs +10 -0
  16. package/dist/handle-UG5M2OON.mjs +22 -0
  17. package/dist/multiline-OHSNFCRG.mjs +40 -0
  18. package/dist/project-NT3L4FTB.mjs +28 -0
  19. package/dist/resolver-WSFWKACM.mjs +153 -0
  20. package/dist/sprint-4VHDLGFN.mjs +37 -0
  21. package/dist/wizard-LRELAN2J.mjs +196 -0
  22. package/package.json +19 -28
  23. package/CHANGELOG.md +0 -94
  24. package/bin/ralphctl +0 -13
  25. package/src/ai/executor.ts +0 -973
  26. package/src/ai/lifecycle.ts +0 -45
  27. package/src/ai/parser.ts +0 -40
  28. package/src/ai/permissions.ts +0 -207
  29. package/src/ai/process-manager.ts +0 -248
  30. package/src/ai/prompts/index.ts +0 -89
  31. package/src/ai/rate-limiter.ts +0 -89
  32. package/src/ai/runner.ts +0 -478
  33. package/src/ai/session.ts +0 -319
  34. package/src/ai/task-context.ts +0 -270
  35. package/src/cli-metadata.ts +0 -7
  36. package/src/cli.ts +0 -65
  37. package/src/commands/completion/index.ts +0 -33
  38. package/src/commands/config/config.ts +0 -58
  39. package/src/commands/config/index.ts +0 -33
  40. package/src/commands/dashboard/dashboard.ts +0 -5
  41. package/src/commands/dashboard/index.ts +0 -6
  42. package/src/commands/doctor/doctor.ts +0 -271
  43. package/src/commands/doctor/index.ts +0 -25
  44. package/src/commands/progress/index.ts +0 -25
  45. package/src/commands/progress/log.ts +0 -64
  46. package/src/commands/progress/show.ts +0 -14
  47. package/src/commands/project/add.ts +0 -336
  48. package/src/commands/project/index.ts +0 -104
  49. package/src/commands/project/list.ts +0 -31
  50. package/src/commands/project/remove.ts +0 -43
  51. package/src/commands/project/repo.ts +0 -118
  52. package/src/commands/project/show.ts +0 -49
  53. package/src/commands/sprint/close.ts +0 -180
  54. package/src/commands/sprint/context.ts +0 -109
  55. package/src/commands/sprint/create.ts +0 -60
  56. package/src/commands/sprint/current.ts +0 -75
  57. package/src/commands/sprint/delete.ts +0 -72
  58. package/src/commands/sprint/health.ts +0 -229
  59. package/src/commands/sprint/ideate.ts +0 -496
  60. package/src/commands/sprint/index.ts +0 -226
  61. package/src/commands/sprint/list.ts +0 -86
  62. package/src/commands/sprint/plan-utils.ts +0 -207
  63. package/src/commands/sprint/plan.ts +0 -549
  64. package/src/commands/sprint/refine.ts +0 -359
  65. package/src/commands/sprint/requirements.ts +0 -58
  66. package/src/commands/sprint/show.ts +0 -140
  67. package/src/commands/sprint/start.ts +0 -119
  68. package/src/commands/sprint/switch.ts +0 -20
  69. package/src/commands/task/add.ts +0 -316
  70. package/src/commands/task/import.ts +0 -150
  71. package/src/commands/task/index.ts +0 -123
  72. package/src/commands/task/list.ts +0 -145
  73. package/src/commands/task/next.ts +0 -45
  74. package/src/commands/task/remove.ts +0 -47
  75. package/src/commands/task/reorder.ts +0 -45
  76. package/src/commands/task/show.ts +0 -111
  77. package/src/commands/task/status.ts +0 -99
  78. package/src/commands/ticket/add.ts +0 -265
  79. package/src/commands/ticket/edit.ts +0 -166
  80. package/src/commands/ticket/index.ts +0 -114
  81. package/src/commands/ticket/list.ts +0 -128
  82. package/src/commands/ticket/refine-utils.ts +0 -89
  83. package/src/commands/ticket/refine.ts +0 -268
  84. package/src/commands/ticket/remove.ts +0 -48
  85. package/src/commands/ticket/show.ts +0 -74
  86. package/src/completion/handle.ts +0 -30
  87. package/src/completion/resolver.ts +0 -241
  88. package/src/interactive/dashboard.ts +0 -268
  89. package/src/interactive/escapable.ts +0 -81
  90. package/src/interactive/file-browser.ts +0 -153
  91. package/src/interactive/index.ts +0 -429
  92. package/src/interactive/menu.ts +0 -403
  93. package/src/interactive/selectors.ts +0 -273
  94. package/src/interactive/wizard.ts +0 -221
  95. package/src/providers/claude.ts +0 -53
  96. package/src/providers/copilot.ts +0 -86
  97. package/src/providers/index.ts +0 -43
  98. package/src/providers/types.ts +0 -85
  99. package/src/schemas/index.ts +0 -130
  100. package/src/store/config.ts +0 -74
  101. package/src/store/progress.ts +0 -230
  102. package/src/store/project.ts +0 -276
  103. package/src/store/sprint.ts +0 -229
  104. package/src/store/task.ts +0 -443
  105. package/src/store/ticket.ts +0 -178
  106. package/src/theme/index.ts +0 -215
  107. package/src/theme/ui.ts +0 -872
  108. package/src/utils/detect-scripts.ts +0 -247
  109. package/src/utils/editor-input.ts +0 -41
  110. package/src/utils/editor.ts +0 -37
  111. package/src/utils/exit-codes.ts +0 -27
  112. package/src/utils/file-lock.ts +0 -135
  113. package/src/utils/git.ts +0 -185
  114. package/src/utils/ids.ts +0 -37
  115. package/src/utils/issue-fetch.ts +0 -244
  116. package/src/utils/json-extract.ts +0 -62
  117. package/src/utils/multiline.ts +0 -61
  118. package/src/utils/path-selector.ts +0 -236
  119. package/src/utils/paths.ts +0 -108
  120. package/src/utils/provider.ts +0 -34
  121. package/src/utils/requirements-export.ts +0 -63
  122. package/src/utils/storage.ts +0 -107
  123. package/tsconfig.json +0 -25
  124. /package/{src/ai → dist}/prompts/ideate-auto.md +0 -0
  125. /package/{src/ai → dist}/prompts/ideate.md +0 -0
  126. /package/{src/ai → dist}/prompts/plan-auto.md +0 -0
  127. /package/{src/ai → dist}/prompts/plan-common.md +0 -0
  128. /package/{src/ai → dist}/prompts/plan-interactive.md +0 -0
  129. /package/{src/ai → dist}/prompts/task-execution.md +0 -0
  130. /package/{src/ai → dist}/prompts/ticket-refine.md +0 -0
package/src/utils/ids.ts DELETED
@@ -1,37 +0,0 @@
1
- import { randomBytes } from 'node:crypto';
2
-
3
- /**
4
- * Generate an 8-character UUID-like ID.
5
- * Used for tickets and tasks.
6
- */
7
- export function generateUuid8(): string {
8
- return randomBytes(4).toString('hex');
9
- }
10
-
11
- /**
12
- * Sanitize a string into a URL/filesystem-safe slug.
13
- * Lowercase, alphanumeric + hyphens only, max 40 characters.
14
- */
15
- export function slugify(input: string, maxLength = 40): string {
16
- return input
17
- .toLowerCase()
18
- .replace(/[^a-z0-9]+/g, '-')
19
- .replace(/^-+|-+$/g, '')
20
- .replace(/-{2,}/g, '-')
21
- .slice(0, maxLength)
22
- .replace(/-$/, ''); // Remove trailing hyphen if truncation created one
23
- }
24
-
25
- /**
26
- * Generate a sprint ID in the format: YYYYMMDD-HHmmss-<slug>
27
- * Lexicographically sortable by creation time.
28
- */
29
- export function generateSprintId(name?: string): string {
30
- const now = new Date();
31
- // Format: YYYYMMDD-HHmmss (remove non-digits from ISO string parts)
32
- const date = now.toISOString().slice(0, 10).replace(/-/g, '');
33
- const time = now.toISOString().slice(11, 19).replace(/:/g, '');
34
- const slug = name ? slugify(name) : generateUuid8();
35
-
36
- return `${date}-${time}-${slug || generateUuid8()}`;
37
- }
@@ -1,244 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
-
3
- const MAX_COMMENTS = 20;
4
-
5
- export interface IssueComment {
6
- author: string;
7
- createdAt: string;
8
- body: string;
9
- }
10
-
11
- export interface IssueData {
12
- title: string;
13
- body: string;
14
- comments: IssueComment[];
15
- url: string;
16
- }
17
-
18
- export interface ParsedIssueUrl {
19
- host: 'github' | 'gitlab';
20
- hostname: string;
21
- owner: string;
22
- repo: string;
23
- number: number;
24
- }
25
-
26
- /**
27
- * Parse a GitHub or GitLab issue URL into its components.
28
- * Returns null if the URL is not a recognized issue URL.
29
- *
30
- * GitHub: https://github.com/owner/repo/issues/123
31
- * GitLab: https://gitlab.example.com/group/project/-/issues/456
32
- * (any hostname with /-/issues/ path segment)
33
- */
34
- export function parseIssueUrl(url: string): ParsedIssueUrl | null {
35
- let parsed: URL;
36
- try {
37
- parsed = new URL(url);
38
- } catch {
39
- return null;
40
- }
41
-
42
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
43
- return null;
44
- }
45
-
46
- const segments = parsed.pathname.split('/').filter(Boolean);
47
-
48
- // GitHub: /owner/repo/issues/123
49
- if (parsed.hostname === 'github.com') {
50
- const owner = segments[0];
51
- const repo = segments[1];
52
- if (segments.length >= 4 && segments[2] === 'issues' && owner && repo) {
53
- const num = Number(segments[3]);
54
- if (Number.isInteger(num) && num > 0) {
55
- return { host: 'github', hostname: parsed.hostname, owner, repo, number: num };
56
- }
57
- }
58
- return null;
59
- }
60
-
61
- // GitLab (any hostname): /.../group/project/-/issues/456
62
- const dashIdx = segments.indexOf('-');
63
- if (dashIdx >= 2 && segments[dashIdx + 1] === 'issues') {
64
- const num = Number(segments[dashIdx + 2]);
65
- if (Number.isInteger(num) && num > 0) {
66
- const repo = segments[dashIdx - 1];
67
- if (repo) {
68
- const owner = segments.slice(0, dashIdx - 1).join('/');
69
- return { host: 'gitlab', hostname: parsed.hostname, owner, repo, number: num };
70
- }
71
- }
72
- }
73
-
74
- return null;
75
- }
76
-
77
- interface GhComment {
78
- author?: { login?: string };
79
- body?: string;
80
- createdAt?: string;
81
- }
82
-
83
- interface GhIssueResponse {
84
- title?: string;
85
- body?: string;
86
- comments?: GhComment[];
87
- }
88
-
89
- interface GlabNote {
90
- author?: { username?: string };
91
- body?: string;
92
- created_at?: string;
93
- }
94
-
95
- interface GlabIssueResponse {
96
- title?: string;
97
- description?: string;
98
- notes?: GlabNote[];
99
- }
100
-
101
- function fetchGitHubIssue(parsed: ParsedIssueUrl): IssueData {
102
- const result = spawnSync(
103
- 'gh',
104
- [
105
- 'issue',
106
- 'view',
107
- String(parsed.number),
108
- '--repo',
109
- `${parsed.owner}/${parsed.repo}`,
110
- '--json',
111
- 'title,body,comments',
112
- ],
113
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000 }
114
- );
115
-
116
- if (result.status !== 0) {
117
- const stderr = result.stderr.trim();
118
- throw new IssueFetchError(`gh issue view failed: ${stderr || 'unknown error'}`);
119
- }
120
-
121
- const data = JSON.parse(result.stdout) as GhIssueResponse;
122
-
123
- const comments: IssueComment[] = (data.comments ?? []).slice(-MAX_COMMENTS).map((c) => ({
124
- author: c.author?.login ?? 'unknown',
125
- createdAt: c.createdAt ?? '',
126
- body: c.body ?? '',
127
- }));
128
-
129
- return {
130
- title: data.title ?? '',
131
- body: data.body ?? '',
132
- comments,
133
- url: `https://${parsed.hostname}/${parsed.owner}/${parsed.repo}/issues/${String(parsed.number)}`,
134
- };
135
- }
136
-
137
- function fetchGitLabIssue(parsed: ParsedIssueUrl): IssueData {
138
- // Fetch issue details
139
- const result = spawnSync(
140
- 'glab',
141
- ['issue', 'view', String(parsed.number), '--repo', `${parsed.owner}/${parsed.repo}`, '--output', 'json'],
142
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000 }
143
- );
144
-
145
- if (result.status !== 0) {
146
- const stderr = result.stderr.trim();
147
- throw new IssueFetchError(`glab issue view failed: ${stderr || 'unknown error'}`);
148
- }
149
-
150
- const data = JSON.parse(result.stdout) as GlabIssueResponse;
151
-
152
- // Fetch issue notes (comments) separately
153
- const notesResult = spawnSync(
154
- 'glab',
155
- ['issue', 'note', 'list', String(parsed.number), '--repo', `${parsed.owner}/${parsed.repo}`, '--output', 'json'],
156
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000 }
157
- );
158
-
159
- let comments: IssueComment[] = [];
160
- if (notesResult.status === 0 && notesResult.stdout.trim()) {
161
- try {
162
- const notes = JSON.parse(notesResult.stdout) as GlabNote[];
163
- comments = notes.slice(-MAX_COMMENTS).map((n) => ({
164
- author: n.author?.username ?? 'unknown',
165
- createdAt: n.created_at ?? '',
166
- body: n.body ?? '',
167
- }));
168
- } catch {
169
- // Non-fatal — continue without comments
170
- }
171
- }
172
-
173
- return {
174
- title: data.title ?? '',
175
- body: data.description ?? '',
176
- comments,
177
- url: `https://${parsed.hostname}/${parsed.owner}/${parsed.repo}/-/issues/${String(parsed.number)}`,
178
- };
179
- }
180
-
181
- /**
182
- * Fetch issue data from GitHub or GitLab using CLI tools.
183
- * Throws IssueFetchError on failure.
184
- */
185
- export function fetchIssue(parsed: ParsedIssueUrl): IssueData {
186
- if (parsed.host === 'github') {
187
- return fetchGitHubIssue(parsed);
188
- }
189
- return fetchGitLabIssue(parsed);
190
- }
191
-
192
- /**
193
- * Fetch issue data from a URL string. Convenience wrapper around parseIssueUrl + fetchIssue.
194
- * Returns null if the URL is not a recognized issue URL.
195
- * Throws IssueFetchError on fetch failure.
196
- */
197
- export function fetchIssueFromUrl(url: string): IssueData | null {
198
- const parsed = parseIssueUrl(url);
199
- if (!parsed) return null;
200
- return fetchIssue(parsed);
201
- }
202
-
203
- /**
204
- * Format issue data as markdown context for AI prompts.
205
- */
206
- export function formatIssueContext(data: IssueData): string {
207
- const lines: string[] = [];
208
-
209
- lines.push('## Source Issue Data');
210
- lines.push('');
211
- lines.push(`> Fetched live from ${data.url}`);
212
- lines.push('');
213
- lines.push(`**Title:** ${data.title}`);
214
- lines.push('');
215
-
216
- if (data.body) {
217
- lines.push('**Body:**');
218
- lines.push('');
219
- lines.push(data.body);
220
- lines.push('');
221
- }
222
-
223
- if (data.comments.length > 0) {
224
- lines.push(`**Comments (${String(data.comments.length)}):**`);
225
- lines.push('');
226
- for (const comment of data.comments) {
227
- const timestamp = comment.createdAt ? ` (${comment.createdAt})` : '';
228
- lines.push(`---`);
229
- lines.push(`**@${comment.author}**${timestamp}:`);
230
- lines.push('');
231
- lines.push(comment.body);
232
- lines.push('');
233
- }
234
- }
235
-
236
- return lines.join('\n');
237
- }
238
-
239
- export class IssueFetchError extends Error {
240
- constructor(message: string) {
241
- super(message);
242
- this.name = 'IssueFetchError';
243
- }
244
- }
@@ -1,62 +0,0 @@
1
- /**
2
- * Extract a complete JSON structure (array or object) from text that may contain surrounding content.
3
- * Uses depth tracking to handle nested structures and strings containing brackets/braces.
4
- */
5
- function extractJsonStructure(output: string, open: string, close: string, typeName: string): string {
6
- const start = output.indexOf(open);
7
- if (start === -1) {
8
- throw new Error(`No JSON ${typeName} found in output`);
9
- }
10
-
11
- let depth = 0;
12
- let inString = false;
13
- let escape = false;
14
- for (let i = start; i < output.length; i++) {
15
- const ch = output[i];
16
- if (escape) {
17
- escape = false;
18
- continue;
19
- }
20
- if (ch === '\\' && inString) {
21
- escape = true;
22
- continue;
23
- }
24
- if (ch === '"') {
25
- inString = !inString;
26
- continue;
27
- }
28
- if (inString) continue;
29
- if (ch === open) depth++;
30
- if (ch === close) {
31
- depth--;
32
- if (depth === 0) {
33
- return output.slice(start, i + 1);
34
- }
35
- }
36
- }
37
- throw new Error(`No complete JSON ${typeName} found in output`);
38
- }
39
-
40
- /**
41
- * Extract a complete JSON array from text that may contain surrounding content.
42
- * Uses bracket-depth tracking to handle nested arrays and strings containing brackets.
43
- *
44
- * @param output - The text containing a JSON array
45
- * @returns The extracted JSON array string
46
- * @throws Error if no complete JSON array is found
47
- */
48
- export function extractJsonArray(output: string): string {
49
- return extractJsonStructure(output, '[', ']', 'array');
50
- }
51
-
52
- /**
53
- * Extract a complete JSON object from text that may contain surrounding content.
54
- * Uses brace-depth tracking to handle nested objects and strings containing braces.
55
- *
56
- * @param output - The text containing a JSON object
57
- * @returns The extracted JSON object string
58
- * @throws Error if no complete JSON object is found
59
- */
60
- export function extractJsonObject(output: string): string {
61
- return extractJsonStructure(output, '{', '}', 'object');
62
- }
@@ -1,61 +0,0 @@
1
- import * as readline from 'node:readline';
2
- import { muted } from '@src/theme/index.ts';
3
- import { icons } from '@src/theme/ui.ts';
4
-
5
- export interface MultilineInputOptions {
6
- /** Message/prompt to display */
7
- message: string;
8
- /** Default value (will be shown as initial lines) */
9
- default?: string;
10
- /** Hint text shown after the message */
11
- hint?: string;
12
- }
13
-
14
- /**
15
- * Prompt for multiline input with paste support.
16
- * Ctrl+D to finish (standard Unix EOF).
17
- * Supports pasting multiline text including blank lines.
18
- *
19
- * @returns Joined lines as a single string
20
- */
21
- export async function multilineInput(options: MultilineInputOptions): Promise<string> {
22
- const { message, default: defaultValue, hint = 'Ctrl+D to finish' } = options;
23
-
24
- // Show the prompt with hint
25
- console.log(`${icons.edit} ${message} ${muted(`(${hint})`)}`);
26
-
27
- // If there's a default value, show it
28
- if (defaultValue) {
29
- console.log(muted(' Default:'));
30
- for (const line of defaultValue.split('\n')) {
31
- console.log(muted(` ${line}`));
32
- }
33
- console.log('');
34
- }
35
-
36
- const lines: string[] = [];
37
-
38
- const rl = readline.createInterface({
39
- input: process.stdin,
40
- output: process.stdout,
41
- terminal: process.stdin.isTTY,
42
- });
43
-
44
- return new Promise<string>((resolve) => {
45
- rl.on('line', (line) => {
46
- lines.push(line);
47
- });
48
-
49
- rl.on('close', () => {
50
- // Print newline after Ctrl+D for clean formatting
51
- console.log('');
52
-
53
- // Trim trailing empty lines
54
- while (lines.length > 0 && lines.at(-1)?.trim() === '') {
55
- lines.pop();
56
- }
57
-
58
- resolve(lines.join('\n'));
59
- });
60
- });
61
- }
@@ -1,236 +0,0 @@
1
- import { execSync } from 'node:child_process';
2
- import { basename } from 'node:path';
3
- import type { Project, Repository } from '@src/schemas/index.ts';
4
- import { assertSafeCwd } from '@src/utils/paths.ts';
5
-
6
- /**
7
- * Result of path selection for a multi-repo project.
8
- */
9
- export interface PathSelectionResult {
10
- /** Primary working path (where most work will happen) */
11
- primary: string;
12
- /** Additional paths to include for context */
13
- additional: string[];
14
- }
15
-
16
- /**
17
- * Common keywords that map to path patterns.
18
- * Used for simple heuristic matching.
19
- */
20
- const PATH_KEYWORDS: Record<string, string[]> = {
21
- frontend: ['frontend', 'client', 'web', 'ui', 'app'],
22
- backend: ['backend', 'server', 'api', 'service'],
23
- mobile: ['mobile', 'ios', 'android', 'app'],
24
- shared: ['shared', 'common', 'lib', 'core', 'utils'],
25
- docs: ['docs', 'documentation'],
26
- infra: ['infra', 'infrastructure', 'deploy', 'k8s', 'terraform'],
27
- };
28
-
29
- /**
30
- * Extract keywords from text (title, description).
31
- */
32
- function extractKeywords(text: string): Set<string> {
33
- // Normalize and split into words
34
- const words = text
35
- .toLowerCase()
36
- .replace(/[^a-z0-9\s]/g, ' ')
37
- .split(/\s+/)
38
- .filter((w) => w.length > 2);
39
-
40
- return new Set(words);
41
- }
42
-
43
- /**
44
- * Score a repository based on keyword matches.
45
- */
46
- function scoreRepo(repo: Repository, keywords: Set<string>): number {
47
- const repoName = repo.name.toLowerCase();
48
- const pathName = basename(repo.path).toLowerCase();
49
- let score = 0;
50
-
51
- // Check direct keyword matches in repo name or path basename
52
- for (const keyword of keywords) {
53
- if (repoName.includes(keyword) || pathName.includes(keyword)) {
54
- score += 10;
55
- }
56
- }
57
-
58
- // Check category keywords
59
- for (const [category, categoryKeywords] of Object.entries(PATH_KEYWORDS)) {
60
- // If text mentions a category
61
- if (keywords.has(category)) {
62
- for (const catKeyword of categoryKeywords) {
63
- if (repoName.includes(catKeyword) || pathName.includes(catKeyword)) {
64
- score += 5;
65
- }
66
- }
67
- }
68
-
69
- // If text mentions specific keywords from a category
70
- for (const catKeyword of categoryKeywords) {
71
- if (keywords.has(catKeyword) && (repoName.includes(catKeyword) || pathName.includes(catKeyword))) {
72
- score += 8;
73
- }
74
- }
75
- }
76
-
77
- return score;
78
- }
79
-
80
- /**
81
- * Get recently modified paths from git history.
82
- * Returns paths that were modified in the last N commits.
83
- */
84
- function getRecentlyModifiedPaths(repos: Repository[], commits = 50): Map<string, number> {
85
- const pathCounts = new Map<string, number>();
86
-
87
- for (const repo of repos) {
88
- try {
89
- assertSafeCwd(repo.path);
90
- // Get list of modified files from git log
91
- const result = execSync(`git log -${String(commits)} --name-only --pretty=format:`, {
92
- cwd: repo.path,
93
- encoding: 'utf-8',
94
- stdio: ['pipe', 'pipe', 'pipe'],
95
- });
96
-
97
- // Count modifications per path
98
- const currentCount = pathCounts.get(repo.path) ?? 0;
99
- const files = result.split('\n').filter((f) => f.trim());
100
- pathCounts.set(repo.path, currentCount + files.length);
101
- } catch {
102
- // Ignore git errors (path might not be a git repo)
103
- }
104
- }
105
-
106
- return pathCounts;
107
- }
108
-
109
- /**
110
- * Find paths that are commonly co-modified.
111
- * If pathA is often changed with pathB, they might be related.
112
- */
113
- function getCoModifiedPaths(primaryPath: string, allRepos: Repository[]): string[] {
114
- const coModified: string[] = [];
115
-
116
- // This is a simplified version - just return paths that share recent commits
117
- // A full implementation would analyze actual co-commit patterns
118
-
119
- try {
120
- assertSafeCwd(primaryPath);
121
- // Get commit hashes for primary path
122
- const primaryCommits = execSync('git log -20 --pretty=format:%H', {
123
- cwd: primaryPath,
124
- encoding: 'utf-8',
125
- stdio: ['pipe', 'pipe', 'pipe'],
126
- })
127
- .split('\n')
128
- .filter((h) => h.trim());
129
-
130
- const primarySet = new Set(primaryCommits);
131
-
132
- // Check each other path for overlapping commits
133
- for (const repo of allRepos) {
134
- if (repo.path === primaryPath) continue;
135
-
136
- try {
137
- assertSafeCwd(repo.path);
138
- const otherCommits = execSync('git log -20 --pretty=format:%H', {
139
- cwd: repo.path,
140
- encoding: 'utf-8',
141
- stdio: ['pipe', 'pipe', 'pipe'],
142
- })
143
- .split('\n')
144
- .filter((h) => h.trim());
145
-
146
- // Count overlapping commits
147
- const overlap = otherCommits.filter((h) => primarySet.has(h)).length;
148
- if (overlap > 3) {
149
- coModified.push(repo.path);
150
- }
151
- } catch {
152
- // Ignore errors
153
- }
154
- }
155
- } catch {
156
- // Ignore errors
157
- }
158
-
159
- return coModified;
160
- }
161
-
162
- /**
163
- * Select relevant paths for a ticket based on context.
164
- *
165
- * @param project - Project with multiple repositories
166
- * @param context - Ticket context (title, description)
167
- * @returns Selected paths (primary + additional for context)
168
- */
169
- export function selectRelevantPaths(
170
- project: Project,
171
- context: { ticketTitle: string; ticketDescription?: string }
172
- ): PathSelectionResult {
173
- const repos = project.repositories;
174
-
175
- // Single repo project - simple case
176
- if (repos.length <= 1) {
177
- return {
178
- primary: repos[0]?.path ?? '',
179
- additional: [],
180
- };
181
- }
182
-
183
- // Extract keywords from ticket
184
- const text = `${context.ticketTitle} ${context.ticketDescription ?? ''}`;
185
- const keywords = extractKeywords(text);
186
-
187
- // Score each repo based on keywords
188
- const scores = repos.map((repo) => ({
189
- repo,
190
- score: scoreRepo(repo, keywords),
191
- }));
192
-
193
- // Sort by score (highest first)
194
- scores.sort((a, b) => b.score - a.score);
195
-
196
- // If we have clear winners (score > 0), use those
197
- const scoredRepos = scores.filter((s) => s.score > 0);
198
-
199
- if (scoredRepos.length > 0) {
200
- const primary = scoredRepos[0]?.repo.path ?? repos[0]?.path ?? '';
201
- const additional = scoredRepos.slice(1).map((s) => s.repo.path);
202
- return { primary, additional };
203
- }
204
-
205
- // No keyword matches - use activity-based heuristic
206
- const activityScores = getRecentlyModifiedPaths(repos);
207
-
208
- // Sort by activity
209
- const byActivity = repos
210
- .map((repo) => ({
211
- repo,
212
- activity: activityScores.get(repo.path) ?? 0,
213
- }))
214
- .sort((a, b) => b.activity - a.activity);
215
-
216
- const primary = byActivity[0]?.repo.path ?? repos[0]?.path ?? '';
217
-
218
- // Get co-modified paths for context
219
- const coModified = getCoModifiedPaths(primary, repos);
220
-
221
- return {
222
- primary,
223
- additional: coModified,
224
- };
225
- }
226
-
227
- /**
228
- * Select all paths (when user explicitly requests it).
229
- */
230
- export function selectAllPaths(project: Project): PathSelectionResult {
231
- const [primary, ...additional] = project.repositories;
232
- return {
233
- primary: primary?.path ?? '',
234
- additional: additional.map((r) => r.path),
235
- };
236
- }