vibepro 0.1.0-alpha.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,522 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getWorkspaceDir, toWorkspaceRelative } from './workspace.js';
5
+
6
+ export async function createStoryTasks(repoRoot, { story, evidence, runId, gateStatus }) {
7
+ const root = path.resolve(repoRoot);
8
+ const tasksDir = path.join(getWorkspaceDir(root), 'stories', story.story_id, 'tasks');
9
+
10
+ const canonicalTasksJsonPath = path.join(tasksDir, 'tasks.json');
11
+ const existingTaskState = await readTaskStateIfExists(canonicalTasksJsonPath);
12
+ const taskState = buildStoryTaskState({ story, evidence, runId, gateStatus, existingTaskState });
13
+ const outputDir = shouldPreserveCanonicalTasks(existingTaskState)
14
+ ? path.join(getWorkspaceDir(root), 'stories', story.story_id, 'diagnostics', safeRunId(runId))
15
+ : tasksDir;
16
+ await mkdir(outputDir, { recursive: true });
17
+
18
+ const tasksJsonPath = path.join(outputDir, 'tasks.json');
19
+ const tasksMarkdownPath = path.join(outputDir, 'tasks.md');
20
+
21
+ await writeFile(tasksJsonPath, `${JSON.stringify(taskState, null, 2)}\n`);
22
+ await writeFile(tasksMarkdownPath, renderStoryTasks(taskState));
23
+
24
+ return {
25
+ taskState,
26
+ artifacts: {
27
+ story_tasks_json: toWorkspaceRelative(root, tasksJsonPath),
28
+ story_tasks_markdown: toWorkspaceRelative(root, tasksMarkdownPath)
29
+ }
30
+ };
31
+ }
32
+
33
+ async function readTaskStateIfExists(tasksJsonPath) {
34
+ try {
35
+ return JSON.parse(await readFile(tasksJsonPath, 'utf8'));
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT') return null;
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ function shouldPreserveCanonicalTasks(taskState) {
43
+ if (!taskState) return false;
44
+ if (taskState.source_run?.run_id === 'story-plan') return true;
45
+ return (taskState.tasks ?? []).some((task) => task.source_type === 'story_plan_candidate');
46
+ }
47
+
48
+ function safeRunId(runId) {
49
+ return String(runId ?? 'diagnosis-run').replace(/[\\/]/g, '_');
50
+ }
51
+
52
+ export async function readStoryTasks(repoRoot, artifactPath) {
53
+ if (!artifactPath) return emptyStoryTaskState();
54
+ try {
55
+ return JSON.parse(await readFile(path.resolve(repoRoot, artifactPath), 'utf8'));
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') return emptyStoryTaskState();
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ export function buildStoryTaskState({ story, evidence, runId, gateStatus, existingTaskState = null }) {
63
+ const actionCandidates = Array.isArray(evidence.action_candidates) ? evidence.action_candidates : [];
64
+ const findings = Array.isArray(evidence.findings) ? evidence.findings : [];
65
+ const actionFindingIds = new Set(actionCandidates.map((candidate) => candidate.finding_id));
66
+ const activeTasks = [
67
+ ...findings
68
+ .filter((finding) => !actionFindingIds.has(finding.id))
69
+ .filter((finding) => shouldCreateFindingTask(finding))
70
+ .flatMap((finding) => buildFindingTasks({ finding, evidence })),
71
+ ...actionCandidates.map((candidate) => buildActionTask(candidate))
72
+ ];
73
+ const tasks = applyCompletionStatus({
74
+ activeTasks,
75
+ existingTaskState,
76
+ currentSourceIds: new Set([
77
+ ...findings.map((finding) => finding.id),
78
+ ...actionCandidates.map((candidate) => candidate.id)
79
+ ]),
80
+ runId
81
+ })
82
+ .sort(compareTasks)
83
+ .map((task, index) => ({
84
+ ...task,
85
+ order: task.order ?? ((index + 1) * 10)
86
+ }));
87
+
88
+ return {
89
+ schema_version: '0.1.0',
90
+ generated_at: new Date().toISOString(),
91
+ story,
92
+ source_run: {
93
+ run_id: runId,
94
+ gate_status: gateStatus ?? evidence.gates?.[0]?.status ?? 'unknown'
95
+ },
96
+ tasks
97
+ };
98
+ }
99
+
100
+ export function renderStoryTasks(taskState) {
101
+ const tasks = Array.isArray(taskState.tasks) ? taskState.tasks : [];
102
+ return `# VibePro 生成タスク
103
+
104
+ | 項目 | 内容 |
105
+ |------|------|
106
+ | Story | ${taskState.story?.title ?? '-'} |
107
+ | Story ID | ${taskState.story?.story_id ?? '-'} |
108
+ | Run ID | ${taskState.source_run?.run_id ?? '-'} |
109
+ | Gate | ${taskState.source_run?.gate_status ?? '-'} |
110
+ | タスク数 | ${tasks.length} |
111
+
112
+ | ID | Finding | 優先度 | 対象 | 方針 | 状態 |
113
+ |----|---------|--------|------|------|------|
114
+ ${tasks.length === 0 ? '| - | - | - | - | - | - |' : tasks.map((task) => `| ${task.id} | ${task.finding_id ?? '-'} | ${task.priority} | ${task.target_count ?? task.target_files.length}件 | ${task.recommended_strategy?.id ?? '-'} | ${task.status} |`).join('\n')}
115
+
116
+ ${tasks.map(renderTaskDetail).join('\n\n')}`;
117
+ }
118
+
119
+ function renderTaskDetail(task) {
120
+ return `## ${task.id}: ${task.title}
121
+
122
+ - Source: ${task.source_type} / ${task.source_id}
123
+ - Execution: ${task.execution_policy} / mutates_repository=${task.mutates_repository}
124
+ - Target files: ${task.target_files.length === 0 ? '-' : task.target_files.join(', ')}
125
+ - Target groups: ${formatTargetGroups(task.target_groups)}
126
+ - Read first: ${task.read_first_files.length === 0 ? '-' : task.read_first_files.map((item) => item.file).join(', ')}
127
+ - Recommended strategy: ${task.recommended_strategy?.id ?? '-'}
128
+
129
+ 完了条件:
130
+ ${task.acceptance_criteria.length === 0 ? '- 診断結果を確認する' : task.acceptance_criteria.map((item) => `- ${item}`).join('\n')}`;
131
+ }
132
+
133
+ function buildActionTask(candidate) {
134
+ const plan = candidate.implementation_plan ?? {};
135
+ const targetRoutes = plan.pre_fix_briefing?.target_routes ?? [];
136
+ const targetFiles = uniqueFiles(targetRoutes.length > 0
137
+ ? targetRoutes.map((route) => route.file)
138
+ : [
139
+ ...(candidate.target_files ?? []),
140
+ ...(candidate.route_examples ?? []).map((route) => route.file)
141
+ ]);
142
+ const targetGroups = buildTargetGroups({ targetRoutes, candidate, plan });
143
+ return {
144
+ id: candidate.id.replace('VP-ACTION-', 'VP-TASK-'),
145
+ source_type: 'action_candidate',
146
+ source_id: candidate.id,
147
+ finding_id: candidate.finding_id,
148
+ title: candidate.title,
149
+ priority: normalizePriority(plan.priority ?? severityToPriority(candidate.severity)),
150
+ status: 'todo',
151
+ order: resolveActionOrder(candidate),
152
+ execution_policy: candidate.execution_policy ?? 'proposal_only',
153
+ mutates_repository: Boolean(candidate.mutates_repository),
154
+ target_count: targetRoutes.length > 0 ? targetRoutes.length : candidate.target_count ?? targetFiles.length,
155
+ target_files: targetFiles,
156
+ target_routes: targetRoutes,
157
+ target_groups: targetGroups,
158
+ read_first_files: plan.read_first_files ?? [],
159
+ recommended_strategy: plan.pre_fix_briefing?.recommended_strategy ?? null,
160
+ implementation_steps: plan.steps ?? [],
161
+ acceptance_criteria: plan.acceptance_criteria ?? [],
162
+ graph_context: candidate.graph_context ?? null,
163
+ pre_fix_briefing: plan.pre_fix_briefing ?? null
164
+ };
165
+ }
166
+
167
+ function buildFindingTasks({ finding, evidence }) {
168
+ if (finding.id === 'VP-STATIC-002') {
169
+ return buildSecretFindingTasks({ finding, evidence });
170
+ }
171
+ if (finding.id === 'VP-DB-001') {
172
+ return buildDatabaseFindingTasks({ finding, evidence });
173
+ }
174
+ return [buildFindingTask({
175
+ finding,
176
+ targetFiles: resolveFindingTargetFiles(finding, evidence),
177
+ priority: severityToPriority(finding.severity),
178
+ order: resolveFindingOrder(finding),
179
+ gateEffect: null
180
+ })];
181
+ }
182
+
183
+ function buildDatabaseFindingTasks({ finding, evidence }) {
184
+ const hits = (evidence.database_access?.unbounded_find_many ?? [])
185
+ .filter((hit) => hit.gate_effect !== 'info');
186
+ const groups = groupBy(hits, (hit) => resolveDatabaseTaskGroup(hit.file));
187
+ return Object.values(groups).map((group, index) => {
188
+ const targetFiles = uniqueFiles(group.hits.map((hit) => hit.file));
189
+ return buildFindingTask({
190
+ finding,
191
+ id: `VP-TASK-DB-001-${group.id.toUpperCase().replace(/-/g, '_')}`,
192
+ title: `${finding.title}(${group.title})`,
193
+ targetFiles,
194
+ priority: severityToPriority(finding.severity),
195
+ order: 55 + index,
196
+ gateEffect: null,
197
+ targetGroups: [{
198
+ id: group.id,
199
+ title: group.title,
200
+ target_count: targetFiles.length,
201
+ target_files: targetFiles
202
+ }],
203
+ recommendedStrategy: {
204
+ id: 'add-query-boundary',
205
+ reason: 'route/domain単位でtake/skip/cursorまたは集計API分離を入れ、挙動差分を小さくする。'
206
+ },
207
+ acceptanceCriteria: [
208
+ `${group.title} の公開APIまたはユーザー操作に紐づく一覧取得に take/skip/cursor 等の上限がある。`,
209
+ '再診断でこのgroupの未ページング候補が減っている。'
210
+ ]
211
+ });
212
+ });
213
+ }
214
+
215
+ function resolveDatabaseTaskGroup(file) {
216
+ if (file.startsWith('src/app/api/') || file.startsWith('app/api/')) {
217
+ const apiPath = file.replace(/^src\/app\/api\//, '').replace(/^app\/api\//, '');
218
+ const segments = apiPath.split('/').filter(Boolean);
219
+ const first = normalizeRouteSegment(segments[0] ?? 'api');
220
+ const second = normalizeRouteSegment(segments[1] ?? '');
221
+ if (first === 'admin' && second) return { id: `api-admin-${second}`, title: `Admin API / ${second}` };
222
+ if (first === 'analytics' && second) return { id: 'api-analytics', title: 'Analytics API' };
223
+ if (first === 'v1' && second) return { id: `api-v1-${second}`, title: `v1 API / ${second}` };
224
+ if (first === 'projects') return { id: 'api-projects', title: 'Projects API' };
225
+ return { id: `api-${first}`, title: `API / ${first}` };
226
+ }
227
+ if (file.startsWith('src/lib/services/')) {
228
+ const servicePath = file.replace(/^src\/lib\/services\//, '');
229
+ const [first] = servicePath.split('/').filter(Boolean);
230
+ if (first && servicePath.includes('/')) return { id: `services-${slugify(first)}`, title: `Services / ${first}` };
231
+ return { id: 'services-core', title: 'Services / core' };
232
+ }
233
+ return { id: 'runtime-other', title: 'Runtime other' };
234
+ }
235
+
236
+ function normalizeRouteSegment(segment) {
237
+ return slugify(segment.replace(/^\[(.+)\]$/, '$1'));
238
+ }
239
+
240
+ function slugify(value) {
241
+ return String(value || 'unknown')
242
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
243
+ .toLowerCase()
244
+ .replace(/[^a-z0-9]+/g, '-')
245
+ .replace(/^-+|-+$/g, '') || 'unknown';
246
+ }
247
+
248
+ function groupBy(items, keyFn) {
249
+ const groups = {};
250
+ for (const item of items) {
251
+ const key = keyFn(item);
252
+ const id = typeof key === 'string' ? key : key.id;
253
+ const title = typeof key === 'string' ? key : key.title;
254
+ groups[id] ??= { id, title, hits: [] };
255
+ groups[id].hits.push(item);
256
+ }
257
+ return groups;
258
+ }
259
+
260
+ function buildSecretFindingTasks({ finding, evidence }) {
261
+ const blockFiles = resolveSecretTargetFiles(evidence, 'block');
262
+ const reviewFiles = resolveSecretTargetFiles(evidence, 'review');
263
+ return [
264
+ blockFiles.length > 0 ? buildFindingTask({
265
+ finding,
266
+ id: 'VP-TASK-STATIC-002-BLOCK',
267
+ title: `${finding.title}(即時対応)`,
268
+ targetFiles: blockFiles,
269
+ priority: 'critical',
270
+ order: 10,
271
+ gateEffect: 'block'
272
+ }) : null,
273
+ reviewFiles.length > 0 ? buildFindingTask({
274
+ finding,
275
+ id: 'VP-TASK-STATIC-002-REVIEW',
276
+ title: `${finding.title}(要確認)`,
277
+ targetFiles: reviewFiles,
278
+ priority: 'high',
279
+ order: 15,
280
+ gateEffect: 'review'
281
+ }) : null
282
+ ].filter(Boolean);
283
+ }
284
+
285
+ function buildFindingTask({
286
+ finding,
287
+ id = null,
288
+ title = null,
289
+ targetFiles = [],
290
+ priority,
291
+ order,
292
+ gateEffect,
293
+ targetGroups = [],
294
+ recommendedStrategy = null,
295
+ acceptanceCriteria = null
296
+ }) {
297
+ return {
298
+ id: id ?? finding.id.replace('VP-', 'VP-TASK-'),
299
+ source_type: 'finding',
300
+ source_id: finding.id,
301
+ finding_id: finding.id,
302
+ title: title ?? finding.title,
303
+ priority,
304
+ status: 'todo',
305
+ order,
306
+ gate_effect: gateEffect,
307
+ execution_policy: 'proposal_only',
308
+ mutates_repository: false,
309
+ target_count: targetFiles.length,
310
+ target_files: targetFiles,
311
+ target_routes: [],
312
+ target_groups: targetGroups,
313
+ read_first_files: targetFiles.map((file) => ({
314
+ file,
315
+ reason: `検出事項 ${finding.id} の確認対象`
316
+ })),
317
+ recommended_strategy: recommendedStrategy ?? {
318
+ id: 'manual-review',
319
+ reason: finding.recommendation
320
+ },
321
+ implementation_steps: [{
322
+ id: 'review-finding',
323
+ title: '検出内容を確認する',
324
+ detail: finding.detail
325
+ }],
326
+ acceptance_criteria: acceptanceCriteria ?? [finding.recommendation],
327
+ graph_context: finding.graph_context ?? null,
328
+ pre_fix_briefing: null
329
+ };
330
+ }
331
+
332
+ function applyCompletionStatus({ activeTasks, existingTaskState, currentSourceIds, runId }) {
333
+ const activeTaskIds = new Set(activeTasks.map((task) => task.id));
334
+ const completedTasks = (existingTaskState?.tasks ?? [])
335
+ .filter((task) => !activeTaskIds.has(task.id))
336
+ .filter((task) => task.source_type !== 'story_plan_candidate')
337
+ .filter((task) => task.source_id && !currentSourceIds.has(task.source_id))
338
+ .map((task) => ({
339
+ ...task,
340
+ status: 'done',
341
+ completed_at: new Date().toISOString(),
342
+ completion_evidence: {
343
+ run_id: runId,
344
+ reason: `source ${task.source_id} was not detected in the latest diagnosis`
345
+ }
346
+ }));
347
+ return [...activeTasks, ...completedTasks];
348
+ }
349
+
350
+ function shouldCreateFindingTask(finding) {
351
+ if (['VP-DB-001', 'VP-PERF-001'].includes(finding.id)) return true;
352
+ return ['Critical', 'High'].includes(finding.severity);
353
+ }
354
+
355
+ function resolveFindingTargetFiles(finding, evidence) {
356
+ if (Array.isArray(finding.target_files) && finding.target_files.length > 0) {
357
+ return uniqueFiles(finding.target_files);
358
+ }
359
+ if (finding.id === 'VP-STATIC-002') {
360
+ return uniqueFiles([
361
+ ...resolveSecretTargetFiles(evidence, 'block'),
362
+ ...resolveSecretTargetFiles(evidence, 'review')
363
+ ]);
364
+ }
365
+ if (finding.id === 'VP-STATIC-003') {
366
+ return uniqueFiles((evidence.static_site?.xss_risk_hits ?? [])
367
+ .filter((hit) => hit.gate_effect !== 'info')
368
+ .map((hit) => hit.file));
369
+ }
370
+ if (finding.id === 'VP-SEC-004') {
371
+ return uniqueFiles((evidence.code_quality?.authorization_order_risks ?? [])
372
+ .filter((hit) => hit.gate_effect !== 'info')
373
+ .map((hit) => hit.file));
374
+ }
375
+ if (finding.id === 'VP-DB-001') {
376
+ return uniqueFiles((evidence.database_access?.unbounded_find_many ?? [])
377
+ .filter((hit) => hit.gate_effect !== 'info')
378
+ .map((hit) => hit.file));
379
+ }
380
+ if (finding.id === 'VP-PERF-001') {
381
+ return uniqueFiles((evidence.local_dev?.heavy_dev_scripts ?? [])
382
+ .filter((hit) => hit.gate_effect !== 'info')
383
+ .map((hit) => hit.file));
384
+ }
385
+ if (finding.id === 'VP-DRY-001') {
386
+ return uniqueFiles((evidence.code_quality?.duplicate_query_shapes ?? [])
387
+ .filter((hit) => hit.gate_effect !== 'info')
388
+ .flatMap((hit) => hit.files ?? []));
389
+ }
390
+ if (finding.id === 'VP-ARCH-001') {
391
+ return uniqueFiles((evidence.code_quality?.responsibility_hotspots ?? [])
392
+ .filter((hit) => hit.gate_effect !== 'info')
393
+ .map((hit) => hit.file));
394
+ }
395
+ return [];
396
+ }
397
+
398
+ function resolveSecretTargetFiles(evidence, gateEffect) {
399
+ return uniqueFiles((evidence.static_site?.secret_hits ?? [])
400
+ .filter((hit) => hit.gate_effect === gateEffect)
401
+ .map((hit) => hit.file));
402
+ }
403
+
404
+ function buildTargetGroups({ targetRoutes, candidate, plan }) {
405
+ if (!Array.isArray(targetRoutes) || targetRoutes.length === 0) return [];
406
+ const groups = new Map();
407
+ for (const route of targetRoutes) {
408
+ const id = resolveRouteGroupId(route);
409
+ const current = groups.get(id) ?? {
410
+ id,
411
+ title: resolveRouteGroupTitle(id),
412
+ classification: route.classification ?? 'unknown',
413
+ route_count: 0,
414
+ target_files: [],
415
+ routes: [],
416
+ recommended_strategy: plan.pre_fix_briefing?.recommended_strategy ?? null,
417
+ read_first_files: [],
418
+ acceptance_criteria: plan.acceptance_criteria ?? []
419
+ };
420
+ current.route_count += 1;
421
+ current.target_files = uniqueFiles([...current.target_files, route.file]);
422
+ current.routes.push(route);
423
+ groups.set(id, current);
424
+ }
425
+ return [...groups.values()].map((group) => ({
426
+ ...group,
427
+ read_first_files: selectGroupReadFirstFiles(group, plan.read_first_files ?? [])
428
+ }));
429
+ }
430
+
431
+ function resolveRouteGroupId(route) {
432
+ const segments = (route.route_path ?? '')
433
+ .split('/')
434
+ .filter(Boolean)
435
+ .filter((segment) => segment !== 'api' && !/^\[.+\]$/.test(segment));
436
+ if (segments[0] === 'admin') {
437
+ return segments[1] || 'admin';
438
+ }
439
+ if (segments[0] === 'batch-jobs') return 'batch-jobs';
440
+ if (segments[0] === 'companies') return 'companies';
441
+ if (segments[0] === 'pdf-compress') return 'pdf-compress';
442
+ if (segments[0] === 'v1' && segments[1]) return `v1-${segments[1]}`;
443
+ if (segments[0] === 'debug') return segments.slice(0, 2).join('-');
444
+ return segments.slice(0, 2).join('-') || route.classification || 'unknown';
445
+ }
446
+
447
+ function resolveRouteGroupTitle(id) {
448
+ return id
449
+ .split('-')
450
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
451
+ .join(' ');
452
+ }
453
+
454
+ function selectGroupReadFirstFiles(group, readFirstFiles) {
455
+ const files = new Set(group.target_files);
456
+ return (readFirstFiles ?? []).filter((item) => files.has(item.file));
457
+ }
458
+
459
+ function formatTargetGroups(groups = []) {
460
+ if (!Array.isArray(groups) || groups.length === 0) return '-';
461
+ return groups.map((group) => `${group.id}(${group.route_count})`).join(', ');
462
+ }
463
+
464
+ function compareTasks(a, b) {
465
+ return resolveTaskSort(a) - resolveTaskSort(b) || a.id.localeCompare(b.id);
466
+ }
467
+
468
+ function resolveTaskSort(task) {
469
+ if (task.order != null) return task.order;
470
+ const priorityOrder = { critical: 10, high: 50, medium: 80, low: 100 };
471
+ return priorityOrder[task.priority] ?? 100;
472
+ }
473
+
474
+ function resolveFindingOrder(finding) {
475
+ if (finding.id === 'VP-STATIC-002') return 10;
476
+ if (finding.id === 'VP-PERF-001') return 45;
477
+ if (finding.id === 'VP-DB-001') return 55;
478
+ if (finding.severity === 'Critical') return 10;
479
+ if (finding.severity === 'High') return 50;
480
+ return 100;
481
+ }
482
+
483
+ function resolveActionOrder(candidate) {
484
+ if (candidate.id === 'VP-ACTION-API-002') return 20;
485
+ if (candidate.id === 'VP-ACTION-API-003') return 30;
486
+ if (candidate.id === 'VP-ACTION-API-001') return 40;
487
+ if (candidate.id === 'VP-ACTION-DRY-001') return 60;
488
+ if (candidate.id === 'VP-ACTION-ARCH-001') return 70;
489
+ return 60;
490
+ }
491
+
492
+ function severityToPriority(severity) {
493
+ const values = {
494
+ Critical: 'critical',
495
+ High: 'high',
496
+ Medium: 'medium',
497
+ Low: 'low'
498
+ };
499
+ return values[severity] ?? 'medium';
500
+ }
501
+
502
+ function normalizePriority(priority) {
503
+ if (priority === 'critical') return 'critical';
504
+ if (priority === 'high') return 'high';
505
+ if (priority === 'medium') return 'medium';
506
+ if (priority === 'low') return 'low';
507
+ return 'medium';
508
+ }
509
+
510
+ function uniqueFiles(files) {
511
+ return [...new Set((files ?? []).filter(Boolean))];
512
+ }
513
+
514
+ function emptyStoryTaskState() {
515
+ return {
516
+ schema_version: '0.1.0',
517
+ generated_at: null,
518
+ story: null,
519
+ source_run: null,
520
+ tasks: []
521
+ };
522
+ }