@lnilluv/pi-ralph-loop 0.2.1 → 1.0.0

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 (48) hide show
  1. package/.github/workflows/ci.yml +5 -2
  2. package/.github/workflows/release.yml +15 -43
  3. package/README.md +51 -113
  4. package/package.json +13 -5
  5. package/scripts/version-helper.ts +210 -0
  6. package/src/index.ts +1360 -275
  7. package/src/ralph-draft-context.ts +618 -0
  8. package/src/ralph-draft-llm.ts +297 -0
  9. package/src/ralph-draft.ts +33 -0
  10. package/src/ralph.ts +1457 -0
  11. package/src/runner-rpc.ts +434 -0
  12. package/src/runner-state.ts +822 -0
  13. package/src/runner.ts +957 -0
  14. package/src/secret-paths.ts +66 -0
  15. package/src/shims.d.ts +0 -3
  16. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  17. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  18. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  19. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  20. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  21. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  22. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  23. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  24. package/tests/fixtures/parity/research/RALPH.md +45 -0
  25. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  26. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  27. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  28. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  29. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  30. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  31. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  32. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  33. package/tests/index.test.ts +3529 -0
  34. package/tests/parity/README.md +9 -0
  35. package/tests/parity/harness.py +526 -0
  36. package/tests/parity-harness.test.ts +42 -0
  37. package/tests/parity-research-fixture.test.ts +34 -0
  38. package/tests/ralph-draft-context.test.ts +672 -0
  39. package/tests/ralph-draft-llm.test.ts +434 -0
  40. package/tests/ralph-draft.test.ts +168 -0
  41. package/tests/ralph.test.ts +1840 -0
  42. package/tests/runner-event-contract.test.ts +235 -0
  43. package/tests/runner-rpc.test.ts +358 -0
  44. package/tests/runner-state.test.ts +553 -0
  45. package/tests/runner.test.ts +1347 -0
  46. package/tests/secret-paths.test.ts +55 -0
  47. package/tests/version-helper.test.ts +75 -0
  48. package/tsconfig.json +3 -2
@@ -0,0 +1,618 @@
1
+ import { closeSync, openSync, opendirSync, readSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, join, relative, sep } from "node:path";
3
+ import { isSecretBearingPath } from "./secret-paths.ts";
4
+ import { slugifyTask, type DraftMode, type RepoContext, type RepoContextSelectedFile, type RepoSignals } from "./ralph.ts";
5
+
6
+ export const MAX_SCAN_DEPTH = 3;
7
+ export const MAX_CANDIDATE_PATHS = 200;
8
+ export const MAX_SELECTED_FILES = 6;
9
+ export const MAX_FILE_BYTES = 8_000;
10
+ export const MAX_TOTAL_BYTES = 40_000;
11
+
12
+ const EXCLUDED_DIRS = new Set([".git", "node_modules", "dist", "build", "coverage", ".next"]);
13
+ const STOPWORDS = new Set(["fix", "reverse", "engineer", "this", "app", "tests", "the", "and", "to"]);
14
+
15
+ const TOP_LEVEL_PRIORITY = new Map<string, { score: number; reason: string }>([
16
+ ["README.md", { score: 10_000, reason: "repo overview" }],
17
+ ["package.json", { score: 9_900, reason: "package manifest" }],
18
+ ["pyproject.toml", { score: 9_800, reason: "python project manifest" }],
19
+ ["Cargo.toml", { score: 9_700, reason: "cargo manifest" }],
20
+ ["tsconfig.json", { score: 9_600, reason: "typescript config" }],
21
+ ]);
22
+
23
+ const DIRECTORY_PRIORITY_NAMES = new Map<string, { score: number; reason: string }>([
24
+ ["src", { score: 3_000, reason: "source directory" }],
25
+ ["app", { score: 2_900, reason: "application directory" }],
26
+ ["lib", { score: 2_800, reason: "library directory" }],
27
+ ["server", { score: 2_700, reason: "server directory" }],
28
+ ["client", { score: 2_600, reason: "client directory" }],
29
+ ["config", { score: 2_500, reason: "config directory" }],
30
+ ["configs", { score: 2_500, reason: "config directory" }],
31
+ ["settings", { score: 2_500, reason: "config directory" }],
32
+ ["test", { score: 2_400, reason: "test directory" }],
33
+ ["tests", { score: 2_400, reason: "test directory" }],
34
+ ["__tests__", { score: 2_400, reason: "test directory" }],
35
+ ]);
36
+
37
+ const MAX_DIR_ENTRIES_PER_DIR = 200;
38
+ const MAX_PRIORITY_SCAN_ENTRIES_PER_DIR = 1_000;
39
+
40
+ const CONFIG_PRIORITY: Array<{ pattern: RegExp; score: number; reason: string }> = [
41
+ { pattern: /^vitest\.config\.[^.\/]+$/, score: 9_000, reason: "test runner config" },
42
+ { pattern: /^jest\.config\.[^.\/]+$/, score: 8_900, reason: "test runner config" },
43
+ { pattern: /^eslint\.config\.[^.\/]+$/, score: 8_800, reason: "lint config" },
44
+ { pattern: /^vite\.config\.[^.\/]+$/, score: 8_700, reason: "build config" },
45
+ { pattern: /^next\.config\.[^.\/]+$/, score: 8_600, reason: "framework config" },
46
+ { pattern: /^[^.\/]+\.config\.[^.\/]+$/, score: 8_500, reason: "config file" },
47
+ ];
48
+
49
+ const ENTRYPOINT_PRIORITY: Array<{ pattern: RegExp; score: number; reason: string }> = [
50
+ { pattern: /^src\/index\.[^.\/]+$/, score: 8_000, reason: "likely app entrypoint" },
51
+ { pattern: /^src\/main\.[^.\/]+$/, score: 7_900, reason: "likely app entrypoint" },
52
+ { pattern: /^src\/app\.[^.\/]+$/, score: 7_800, reason: "likely app entrypoint" },
53
+ { pattern: /^src\/server\.[^.\/]+$/, score: 7_700, reason: "likely app entrypoint" },
54
+ { pattern: /^(app|server)\.[^.\/]+$/, score: 7_600, reason: "likely app entrypoint" },
55
+ ];
56
+
57
+ type DirectoryPriority = { score: number; reason: string };
58
+
59
+ type DirectoryEntryCandidate = {
60
+ entry: { name: string; isDirectory(): boolean; isFile(): boolean };
61
+ absolutePath: string;
62
+ relativePath: string;
63
+ priority: DirectoryPriority;
64
+ };
65
+
66
+ type Candidate = {
67
+ path: string;
68
+ absolutePath: string;
69
+ depth: number;
70
+ size: number;
71
+ score: number;
72
+ reason: string;
73
+ matchedKeywords: string[];
74
+ category: "top-level" | "config" | "entrypoint" | "test" | "keyword" | "fallback";
75
+ };
76
+
77
+ type RootCandidate =
78
+ | {
79
+ kind: "file";
80
+ name: string;
81
+ absolutePath: string;
82
+ relativePath: string;
83
+ score: ReturnType<typeof scoreCandidate>;
84
+ }
85
+ | {
86
+ kind: "directory";
87
+ name: string;
88
+ absolutePath: string;
89
+ relativePath: string;
90
+ score: DirectoryPriority;
91
+ };
92
+
93
+ function toPosixPath(value: string): string {
94
+ return value.split(sep).join("/");
95
+ }
96
+
97
+ function isExcludedDir(name: string): boolean {
98
+ return EXCLUDED_DIRS.has(name);
99
+ }
100
+
101
+ function isPriorityEntrypointFile(relativePath: string): boolean {
102
+ return ENTRYPOINT_PRIORITY.some((entry) => entry.pattern.test(relativePath));
103
+ }
104
+
105
+ function isTopLevelPriorityFile(relativePath: string): boolean {
106
+ if (relativePath.indexOf("/") !== -1) return false;
107
+ return TOP_LEVEL_PRIORITY.has(relativePath) || CONFIG_PRIORITY.some((entry) => entry.pattern.test(relativePath)) || isPriorityEntrypointFile(relativePath);
108
+ }
109
+
110
+ function isPriorityWildcardFile(relativePath: string): boolean {
111
+ return CONFIG_PRIORITY.some((entry) => entry.pattern.test(relativePath)) || isPriorityEntrypointFile(relativePath);
112
+ }
113
+
114
+ function directoryNamePriority(name: string, keywords: string[]): DirectoryPriority {
115
+ const normalized = name.toLowerCase();
116
+ const fixed = DIRECTORY_PRIORITY_NAMES.get(normalized);
117
+ if (fixed) return fixed;
118
+
119
+ const matchedKeywords = keywords.filter((keyword) => tokenizePath(name).includes(keyword));
120
+ if (matchedKeywords.length > 0) {
121
+ return {
122
+ score: 2_200 + matchedKeywords.length * 100,
123
+ reason: `matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}`,
124
+ };
125
+ }
126
+
127
+ return { score: 0, reason: "directory" };
128
+ }
129
+
130
+ function readDirBounded(
131
+ absolutePath: string,
132
+ limit: number,
133
+ cache: Map<string, Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>>,
134
+ offset = 0,
135
+ ): Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> {
136
+ const cacheKey = `${absolutePath}\0${offset}\0${limit}`;
137
+ const cached = cache.get(cacheKey);
138
+ if (cached) return cached;
139
+
140
+ let dir;
141
+ try {
142
+ dir = opendirSync(absolutePath);
143
+ } catch {
144
+ cache.set(cacheKey, []);
145
+ return [];
146
+ }
147
+
148
+ const entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = [];
149
+ let skipped = 0;
150
+
151
+ try {
152
+ while (entries.length < limit) {
153
+ const entry = dir.readSync();
154
+ if (entry === null) break;
155
+ if (skipped < offset) {
156
+ skipped += 1;
157
+ continue;
158
+ }
159
+ entries.push(entry as { name: string; isDirectory(): boolean; isFile(): boolean });
160
+ }
161
+ } catch {
162
+ // Keep the bounded snapshot gathered so far.
163
+ } finally {
164
+ try {
165
+ dir.closeSync();
166
+ } catch {
167
+ // Ignore close failures after a bounded read.
168
+ }
169
+ }
170
+
171
+ cache.set(cacheKey, entries);
172
+ return entries;
173
+ }
174
+
175
+ function directoryPriority(
176
+ cwd: string,
177
+ absolutePath: string,
178
+ mode: DraftMode,
179
+ keywords: string[],
180
+ dirEntriesCache: Map<string, Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>>,
181
+ directoryPriorityCache: Map<string, DirectoryPriority>,
182
+ ): DirectoryPriority {
183
+ const cached = directoryPriorityCache.get(absolutePath);
184
+ if (cached) return cached;
185
+
186
+ let best = directoryNamePriority(basename(absolutePath), keywords);
187
+ const entries = readDirBounded(absolutePath, MAX_DIR_ENTRIES_PER_DIR, dirEntriesCache);
188
+
189
+ for (const entry of entries) {
190
+ if (!entry.isFile()) continue;
191
+
192
+ const childAbsolutePath = join(absolutePath, entry.name);
193
+ const childRelativePath = toPosixPath(relative(cwd, childAbsolutePath));
194
+ if (!childRelativePath || childRelativePath.startsWith("..")) continue;
195
+ if (isSecretBearingPath(childRelativePath)) continue;
196
+
197
+ const childScore = scoreCandidate(childRelativePath, mode, keywords);
198
+ if (childScore.score > best.score) {
199
+ best = { score: childScore.score, reason: childScore.reason };
200
+ }
201
+ }
202
+
203
+ directoryPriorityCache.set(absolutePath, best);
204
+ return best;
205
+ }
206
+
207
+ function fileDepth(relativePath: string): number {
208
+ const normalized = toPosixPath(relativePath);
209
+ if (!normalized || normalized === ".") return 0;
210
+ return normalized.split("/").length - 1;
211
+ }
212
+
213
+ function collectCandidates(cwd: string, mode: DraftMode, keywords: string[], signals: RepoSignals): Candidate[] {
214
+ const candidates: Candidate[] = [];
215
+ const seenPaths = new Set<string>();
216
+ const directoryEntriesCache = new Map<string, Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>>();
217
+ const directoryPriorityCache = new Map<string, DirectoryPriority>();
218
+ const rootFileCandidates = new Map<string, Candidate>();
219
+ const rootDirectoryCandidates = new Map<string, { absolutePath: string; relativePath: string; priority: DirectoryPriority }>();
220
+ const priorityDirectoryEntriesCache = new Map<string, Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>>();
221
+
222
+ const addCandidate = (candidate: Candidate): void => {
223
+ if (seenPaths.has(candidate.path) || candidates.length >= MAX_CANDIDATE_PATHS) return;
224
+ seenPaths.add(candidate.path);
225
+ candidates.push(candidate);
226
+ };
227
+
228
+ const probeRootFile = (relativePath: string): void => {
229
+ if (rootFileCandidates.has(relativePath)) return;
230
+
231
+ const absolutePath = join(cwd, relativePath);
232
+ let stats;
233
+ try {
234
+ stats = statSync(absolutePath);
235
+ } catch {
236
+ return;
237
+ }
238
+
239
+ if (!stats.isFile()) return;
240
+ if (isSecretBearingPath(relativePath)) return;
241
+
242
+ const { score, reason, matchedKeywords, category } = scoreCandidate(relativePath, mode, keywords);
243
+ rootFileCandidates.set(relativePath, {
244
+ path: relativePath,
245
+ absolutePath,
246
+ depth: fileDepth(relativePath),
247
+ size: stats.size,
248
+ score,
249
+ reason,
250
+ matchedKeywords,
251
+ category,
252
+ });
253
+ };
254
+
255
+ const probePriorityFilesFromDirectory = (absolutePath: string, matcher: (relativePath: string) => boolean): void => {
256
+ const entries = readDirBounded(absolutePath, MAX_PRIORITY_SCAN_ENTRIES_PER_DIR, priorityDirectoryEntriesCache);
257
+
258
+ for (const entry of entries) {
259
+ if (!entry.isFile()) continue;
260
+
261
+ const childAbsolutePath = join(absolutePath, entry.name);
262
+ const childRelativePath = toPosixPath(relative(cwd, childAbsolutePath));
263
+ if (!childRelativePath || childRelativePath.startsWith("..")) continue;
264
+ if (isSecretBearingPath(childRelativePath)) continue;
265
+ if (!matcher(childRelativePath)) continue;
266
+
267
+ probeRootFile(childRelativePath);
268
+ }
269
+ };
270
+
271
+ const probeRootDirectory = (relativePath: string): void => {
272
+ if (rootDirectoryCandidates.has(relativePath)) return;
273
+
274
+ const absolutePath = join(cwd, relativePath);
275
+ let stats;
276
+ try {
277
+ stats = statSync(absolutePath);
278
+ } catch {
279
+ return;
280
+ }
281
+
282
+ if (!stats.isDirectory()) return;
283
+ if (isExcludedDir(basename(relativePath)) || isSecretBearingPath(relativePath)) return;
284
+
285
+ const priority = directoryPriority(cwd, absolutePath, mode, keywords, directoryEntriesCache, directoryPriorityCache);
286
+ rootDirectoryCandidates.set(relativePath, { absolutePath, relativePath, priority });
287
+ };
288
+
289
+ for (const relativePath of signals.topLevelFiles) {
290
+ probeRootFile(relativePath);
291
+ }
292
+ for (const relativePath of TOP_LEVEL_PRIORITY.keys()) {
293
+ probeRootFile(relativePath);
294
+ }
295
+
296
+ probePriorityFilesFromDirectory(cwd, isTopLevelPriorityFile);
297
+ probePriorityFilesFromDirectory(join(cwd, "src"), isPriorityWildcardFile);
298
+
299
+ for (const relativePath of signals.topLevelDirs) {
300
+ probeRootDirectory(relativePath);
301
+ }
302
+ for (const relativePath of DIRECTORY_PRIORITY_NAMES.keys()) {
303
+ probeRootDirectory(relativePath);
304
+ }
305
+
306
+ const rootFiles = [...rootFileCandidates.values()].sort((left, right) => {
307
+ if (right.score !== left.score) return right.score - left.score;
308
+ return left.path.localeCompare(right.path);
309
+ });
310
+
311
+ for (const entry of rootFiles) {
312
+ addCandidate(entry);
313
+ }
314
+
315
+ const rootDirectories = [...rootDirectoryCandidates.values()].sort((left, right) => {
316
+ if (right.priority.score !== left.priority.score) return right.priority.score - left.priority.score;
317
+ return left.relativePath.localeCompare(right.relativePath);
318
+ });
319
+
320
+ const visit = (currentDir: string, depth: number): void => {
321
+ if (candidates.length >= MAX_CANDIDATE_PATHS || depth > MAX_SCAN_DEPTH) return;
322
+
323
+ const entries = readDirBounded(currentDir, MAX_DIR_ENTRIES_PER_DIR, directoryEntriesCache);
324
+ const extraEntries =
325
+ entries.length === MAX_DIR_ENTRIES_PER_DIR
326
+ ? readDirBounded(currentDir, MAX_DIR_ENTRIES_PER_DIR, directoryEntriesCache, MAX_DIR_ENTRIES_PER_DIR)
327
+ : [];
328
+ const scannedEntries = extraEntries.length > 0 ? [...entries, ...extraEntries] : entries;
329
+ const fileEntries = scannedEntries
330
+ .filter((entry) => entry.isFile())
331
+ .map((entry) => {
332
+ const absolutePath = join(currentDir, entry.name);
333
+ const relativePath = toPosixPath(relative(cwd, absolutePath));
334
+ return {
335
+ entry,
336
+ absolutePath,
337
+ relativePath,
338
+ isEntrypoint: isPriorityEntrypointFile(relativePath),
339
+ };
340
+ })
341
+ .sort((left, right) => {
342
+ if (left.isEntrypoint !== right.isEntrypoint) return Number(right.isEntrypoint) - Number(left.isEntrypoint);
343
+ return left.entry.name.localeCompare(right.entry.name);
344
+ });
345
+
346
+ const directoryEntries: DirectoryEntryCandidate[] = scannedEntries
347
+ .filter((entry) => entry.isDirectory() && !isExcludedDir(entry.name))
348
+ .map((entry) => {
349
+ const absolutePath = join(currentDir, entry.name);
350
+ const relativePath = toPosixPath(relative(cwd, absolutePath));
351
+ if (!relativePath || relativePath.startsWith("..") || isSecretBearingPath(relativePath)) {
352
+ return null;
353
+ }
354
+
355
+ return {
356
+ entry,
357
+ absolutePath,
358
+ relativePath,
359
+ priority: directoryPriority(cwd, absolutePath, mode, keywords, directoryEntriesCache, directoryPriorityCache),
360
+ };
361
+ })
362
+ .filter((directoryEntry): directoryEntry is DirectoryEntryCandidate => directoryEntry !== null)
363
+ .sort((left, right) => {
364
+ if (right.priority.score !== left.priority.score) return right.priority.score - left.priority.score;
365
+ return left.entry.name.localeCompare(right.entry.name);
366
+ });
367
+
368
+ const addFileCandidate = (fileEntry: (typeof fileEntries)[number], candidate: ReturnType<typeof scoreCandidate>): void => {
369
+ if (candidates.length >= MAX_CANDIDATE_PATHS) return;
370
+ if (!fileEntry.relativePath || fileEntry.relativePath.startsWith("..")) return;
371
+ if (isSecretBearingPath(fileEntry.relativePath)) return;
372
+
373
+ const size = (() => {
374
+ try {
375
+ return statSync(fileEntry.absolutePath).size;
376
+ } catch {
377
+ return 0;
378
+ }
379
+ })();
380
+
381
+ addCandidate({
382
+ path: fileEntry.relativePath,
383
+ absolutePath: fileEntry.absolutePath,
384
+ depth: fileDepth(fileEntry.relativePath),
385
+ size,
386
+ score: candidate.score,
387
+ reason: candidate.reason,
388
+ matchedKeywords: candidate.matchedKeywords,
389
+ category: candidate.category,
390
+ });
391
+ };
392
+
393
+ const materializedFileEntries = fileEntries.map((fileEntry) => ({
394
+ fileEntry,
395
+ candidate: scoreCandidate(fileEntry.relativePath, mode, keywords),
396
+ }));
397
+
398
+ if (extraEntries.length > 0) {
399
+ const priorityFileEntries = materializedFileEntries
400
+ .filter(({ candidate }) => candidate.category !== "fallback")
401
+ .sort((left, right) => {
402
+ if (right.candidate.score !== left.candidate.score) return right.candidate.score - left.candidate.score;
403
+ return left.fileEntry.entry.name.localeCompare(right.fileEntry.entry.name);
404
+ });
405
+
406
+ for (const { fileEntry, candidate } of priorityFileEntries) {
407
+ addFileCandidate(fileEntry, candidate);
408
+ }
409
+
410
+ for (const directoryEntry of directoryEntries) {
411
+ if (candidates.length >= MAX_CANDIDATE_PATHS) return;
412
+ if (!directoryEntry.relativePath || directoryEntry.relativePath.startsWith("..")) continue;
413
+ visit(directoryEntry.absolutePath, depth + 1);
414
+ }
415
+
416
+ const fallbackFileEntries = materializedFileEntries
417
+ .filter(({ candidate }) => candidate.category === "fallback")
418
+ .sort((left, right) => {
419
+ if (right.candidate.score !== left.candidate.score) return right.candidate.score - left.candidate.score;
420
+ return left.fileEntry.entry.name.localeCompare(right.fileEntry.entry.name);
421
+ });
422
+
423
+ for (const { fileEntry, candidate } of fallbackFileEntries) {
424
+ addFileCandidate(fileEntry, candidate);
425
+ }
426
+ } else {
427
+ for (const { fileEntry, candidate } of materializedFileEntries) {
428
+ addFileCandidate(fileEntry, candidate);
429
+ }
430
+
431
+ for (const directoryEntry of directoryEntries) {
432
+ if (candidates.length >= MAX_CANDIDATE_PATHS) return;
433
+ if (!directoryEntry.relativePath || directoryEntry.relativePath.startsWith("..")) continue;
434
+ visit(directoryEntry.absolutePath, depth + 1);
435
+ }
436
+ }
437
+ };
438
+
439
+ for (const rootDirectory of rootDirectories) {
440
+ if (candidates.length >= MAX_CANDIDATE_PATHS) break;
441
+ visit(rootDirectory.absolutePath, 1);
442
+ }
443
+
444
+ return candidates;
445
+ }
446
+
447
+ function taskKeywords(task: string): string[] {
448
+ const keywords = slugifyTask(task)
449
+ .split("-")
450
+ .map((part) => part.trim())
451
+ .filter((part) => part.length > 0 && !STOPWORDS.has(part));
452
+ return [...new Set(keywords)];
453
+ }
454
+
455
+ function tokenizePath(relativePath: string): string[] {
456
+ return relativePath
457
+ .toLowerCase()
458
+ .split(/[^a-z0-9]+/g)
459
+ .map((part) => part.trim())
460
+ .filter(Boolean);
461
+ }
462
+
463
+ function scoreCandidate(relativePath: string, mode: DraftMode, keywords: string[]): Pick<Candidate, "score" | "reason" | "matchedKeywords" | "category"> {
464
+ const pathTokens = tokenizePath(relativePath);
465
+ const basenameTokens = tokenizePath(basename(relativePath));
466
+ const matchedKeywords = keywords.filter((keyword) => pathTokens.includes(keyword) || basenameTokens.includes(keyword));
467
+ const fixMode = isFixMode(mode);
468
+ const isTopLevel = relativePath.indexOf("/") === -1;
469
+ const topLevelPriority = isTopLevel ? TOP_LEVEL_PRIORITY.get(relativePath) : undefined;
470
+
471
+ if (topLevelPriority) {
472
+ let score = topLevelPriority.score;
473
+ if (matchedKeywords.length > 0) score += matchedKeywords.length * 75;
474
+ if (fixMode && matchedKeywords.length > 0) score += 100;
475
+ return {
476
+ score,
477
+ reason: matchedKeywords.length > 0 ? `${topLevelPriority.reason}; matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}` : topLevelPriority.reason,
478
+ matchedKeywords,
479
+ category: "top-level",
480
+ };
481
+ }
482
+
483
+ const configPriority = isTopLevel ? CONFIG_PRIORITY.find((entry) => entry.pattern.test(relativePath)) : undefined;
484
+ if (configPriority) {
485
+ let score = configPriority.score;
486
+ if (matchedKeywords.length > 0) score += matchedKeywords.length * 80;
487
+ if (fixMode && matchedKeywords.length > 0) score += 100;
488
+ return {
489
+ score,
490
+ reason: matchedKeywords.length > 0 ? `${configPriority.reason}; matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}` : configPriority.reason,
491
+ matchedKeywords,
492
+ category: "config",
493
+ };
494
+ }
495
+
496
+ const entrypointPriority = ENTRYPOINT_PRIORITY.find((entry) => entry.pattern.test(relativePath));
497
+ if (entrypointPriority) {
498
+ let score = entrypointPriority.score;
499
+ if (matchedKeywords.length > 0) score += matchedKeywords.length * 120;
500
+ if (fixMode && matchedKeywords.length > 0) score += 125;
501
+ return {
502
+ score,
503
+ reason: matchedKeywords.length > 0 ? `${entrypointPriority.reason}; matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}` : entrypointPriority.reason,
504
+ matchedKeywords,
505
+ category: "entrypoint",
506
+ };
507
+ }
508
+
509
+ const isTestFile = /(^|\/)tests?(\/|\.|-)|\.(test|spec)\.[^.\/]+$|__tests__/.test(relativePath.toLowerCase());
510
+ if (isTestFile) {
511
+ let score = fixMode ? 7_200 : 4_000;
512
+ if (matchedKeywords.length > 0) score += matchedKeywords.length * (fixMode ? 250 : 100);
513
+ return {
514
+ score,
515
+ reason: matchedKeywords.length > 0 ? `test file; matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}` : "test file",
516
+ matchedKeywords,
517
+ category: "test",
518
+ };
519
+ }
520
+
521
+ let score = fixMode ? 3_000 : 2_000;
522
+ if (matchedKeywords.length > 0) score += matchedKeywords.length * (fixMode ? 200 : 90);
523
+ if (relativePath.startsWith("src/")) score += fixMode ? 150 : 100;
524
+ return {
525
+ score,
526
+ reason: matchedKeywords.length > 0 ? `related file; matches task keyword${matchedKeywords.length > 1 ? "s" : ""} ${matchedKeywords.join(", ")}` : "related file",
527
+ matchedKeywords,
528
+ category: matchedKeywords.length > 0 ? "keyword" : "fallback",
529
+ };
530
+ }
531
+
532
+ function loadFileContent(absolutePath: string, byteLimit: number): { content: string; bytes: number } {
533
+ const fileDescriptor = openSync(absolutePath, "r");
534
+
535
+ try {
536
+ const buffer = Buffer.alloc(byteLimit);
537
+ const bytesRead = readSync(fileDescriptor, buffer, 0, byteLimit, 0);
538
+ return { content: buffer.subarray(0, bytesRead).toString("utf8"), bytes: bytesRead };
539
+ } finally {
540
+ closeSync(fileDescriptor);
541
+ }
542
+ }
543
+
544
+ function isFixMode(mode: DraftMode): boolean {
545
+ return mode === "fix";
546
+ }
547
+
548
+ function scoreCandidates(cwd: string, task: string, mode: DraftMode, signals: RepoSignals): Candidate[] {
549
+ const keywords = taskKeywords(task);
550
+ const candidates = collectCandidates(cwd, mode, keywords, signals);
551
+
552
+ return candidates.sort((left, right) => {
553
+ if (right.score !== left.score) return right.score - left.score;
554
+ if (left.size !== right.size) return left.size - right.size;
555
+ return left.path.localeCompare(right.path);
556
+ });
557
+ }
558
+
559
+ function selectFiles(candidates: Candidate[]): RepoContextSelectedFile[] {
560
+ const selected: RepoContextSelectedFile[] = [];
561
+ let totalBytes = 0;
562
+
563
+ for (const candidate of candidates) {
564
+ if (selected.length >= MAX_SELECTED_FILES || totalBytes >= MAX_TOTAL_BYTES) break;
565
+
566
+ const remainingBytes = MAX_TOTAL_BYTES - totalBytes;
567
+ if (remainingBytes <= 0) break;
568
+
569
+ const byteLimit = Math.min(MAX_FILE_BYTES, remainingBytes);
570
+ if (byteLimit <= 0) break;
571
+
572
+ let content = "";
573
+ let bytes = 0;
574
+ try {
575
+ ({ content, bytes } = loadFileContent(candidate.absolutePath, byteLimit));
576
+ } catch {
577
+ continue;
578
+ }
579
+
580
+ if (bytes <= 0) continue;
581
+
582
+ totalBytes += bytes;
583
+ selected.push({
584
+ path: candidate.path,
585
+ content,
586
+ reason: candidate.reason,
587
+ });
588
+ }
589
+
590
+ return selected;
591
+ }
592
+
593
+ function summarizeSignals(signals: RepoSignals): string[] {
594
+ const scripts = [signals.testCommand ? `test=${signals.testCommand}` : undefined, signals.lintCommand ? `lint=${signals.lintCommand}` : undefined].filter((value): value is string => Boolean(value));
595
+
596
+ return [
597
+ `package manager: ${signals.packageManager ?? "unknown"}`,
598
+ `scripts: ${scripts.length > 0 ? scripts.join(", ") : "none"}`,
599
+ `git repository: ${signals.hasGit ? "present" : "absent"}`,
600
+ `top-level dirs: ${signals.topLevelDirs.filter((relativePath) => !isSecretBearingPath(relativePath)).length > 0 ? signals.topLevelDirs.filter((relativePath) => !isSecretBearingPath(relativePath)).join(", ") : "none"}`,
601
+ `top-level files: ${signals.topLevelFiles.filter((relativePath) => !isSecretBearingPath(relativePath)).length > 0 ? signals.topLevelFiles.filter((relativePath) => !isSecretBearingPath(relativePath)).join(", ") : "none"}`,
602
+ ];
603
+ }
604
+
605
+ function summarizeSelectedFiles(selectedFiles: RepoContextSelectedFile[]): string {
606
+ if (selectedFiles.length === 0) return "selected files: none";
607
+ return `selected files: ${selectedFiles.map((file) => `${file.path} (${file.reason})`).join("; ")}`;
608
+ }
609
+
610
+ export function assembleRepoContext(cwd: string, task: string, mode: DraftMode, signals: RepoSignals): RepoContext {
611
+ const candidates = scoreCandidates(cwd, task, mode, signals);
612
+ const selectedFiles = selectFiles(candidates);
613
+
614
+ return {
615
+ summaryLines: [...summarizeSignals(signals), summarizeSelectedFiles(selectedFiles)],
616
+ selectedFiles,
617
+ };
618
+ }