@ncukondo/search-hub 0.18.0 → 0.20.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 (83) hide show
  1. package/dist/cli/commands/register.d.ts +1 -1
  2. package/dist/cli/commands/register.js.map +1 -1
  3. package/dist/cli/commands/related.d.ts +66 -0
  4. package/dist/cli/commands/related.d.ts.map +1 -0
  5. package/dist/cli/commands/related.js +161 -0
  6. package/dist/cli/commands/related.js.map +1 -0
  7. package/dist/cli/commands/review/extract.d.ts.map +1 -1
  8. package/dist/cli/commands/review/extract.js +15 -5
  9. package/dist/cli/commands/review/extract.js.map +1 -1
  10. package/dist/cli/commands/review/finalize.js +6 -6
  11. package/dist/cli/commands/review/finalize.js.map +1 -1
  12. package/dist/cli/commands/review/init.d.ts +2 -3
  13. package/dist/cli/commands/review/init.d.ts.map +1 -1
  14. package/dist/cli/commands/review/init.js +1 -0
  15. package/dist/cli/commands/review/init.js.map +1 -1
  16. package/dist/cli/commands/review/list.d.ts +1 -1
  17. package/dist/cli/commands/review/list.d.ts.map +1 -1
  18. package/dist/cli/commands/review/list.js.map +1 -1
  19. package/dist/cli/commands/review/next-steps.d.ts +4 -1
  20. package/dist/cli/commands/review/next-steps.d.ts.map +1 -1
  21. package/dist/cli/commands/review/next-steps.js +53 -19
  22. package/dist/cli/commands/review/next-steps.js.map +1 -1
  23. package/dist/cli/commands/review/schema.d.ts +8 -0
  24. package/dist/cli/commands/review/schema.d.ts.map +1 -1
  25. package/dist/cli/commands/review/schema.js +3 -0
  26. package/dist/cli/commands/review/schema.js.map +1 -1
  27. package/dist/cli/commands/review/status.d.ts +5 -3
  28. package/dist/cli/commands/review/status.d.ts.map +1 -1
  29. package/dist/cli/commands/review/status.js +16 -14
  30. package/dist/cli/commands/review/status.js.map +1 -1
  31. package/dist/cli/commands/review/types.d.ts +7 -6
  32. package/dist/cli/commands/review/types.d.ts.map +1 -1
  33. package/dist/cli/commands/review/types.js +6 -9
  34. package/dist/cli/commands/review/types.js.map +1 -1
  35. package/dist/cli/commands/search-executor.d.ts.map +1 -1
  36. package/dist/cli/commands/search-executor.js +3 -2
  37. package/dist/cli/commands/search-executor.js.map +1 -1
  38. package/dist/cli/commands/search.d.ts +2 -0
  39. package/dist/cli/commands/search.d.ts.map +1 -1
  40. package/dist/cli/commands/search.js +3 -0
  41. package/dist/cli/commands/search.js.map +1 -1
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +131 -6
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/suggestions/rules.d.ts.map +1 -1
  46. package/dist/cli/suggestions/rules.js +19 -3
  47. package/dist/cli/suggestions/rules.js.map +1 -1
  48. package/dist/providers/arxiv/provider.d.ts.map +1 -1
  49. package/dist/providers/arxiv/provider.js +7 -4
  50. package/dist/providers/arxiv/provider.js.map +1 -1
  51. package/dist/providers/base/types.d.ts +8 -0
  52. package/dist/providers/base/types.d.ts.map +1 -1
  53. package/dist/providers/base/types.js.map +1 -1
  54. package/dist/providers/eric/provider.d.ts +3 -0
  55. package/dist/providers/eric/provider.d.ts.map +1 -1
  56. package/dist/providers/eric/provider.js +11 -0
  57. package/dist/providers/eric/provider.js.map +1 -1
  58. package/dist/providers/pubmed/client.d.ts +15 -1
  59. package/dist/providers/pubmed/client.d.ts.map +1 -1
  60. package/dist/providers/pubmed/client.js +64 -1
  61. package/dist/providers/pubmed/client.js.map +1 -1
  62. package/dist/providers/pubmed/index.d.ts +2 -2
  63. package/dist/providers/pubmed/index.d.ts.map +1 -1
  64. package/dist/providers/pubmed/parser.d.ts +8 -1
  65. package/dist/providers/pubmed/parser.d.ts.map +1 -1
  66. package/dist/providers/pubmed/parser.js +23 -1
  67. package/dist/providers/pubmed/parser.js.map +1 -1
  68. package/dist/providers/pubmed/provider.d.ts.map +1 -1
  69. package/dist/providers/pubmed/provider.js +8 -2
  70. package/dist/providers/pubmed/provider.js.map +1 -1
  71. package/dist/providers/pubmed/types.d.ts +29 -0
  72. package/dist/providers/pubmed/types.d.ts.map +1 -1
  73. package/dist/providers/scopus/client.d.ts +2 -0
  74. package/dist/providers/scopus/client.d.ts.map +1 -1
  75. package/dist/providers/scopus/client.js +3 -0
  76. package/dist/providers/scopus/client.js.map +1 -1
  77. package/dist/providers/scopus/provider.d.ts.map +1 -1
  78. package/dist/providers/scopus/provider.js +7 -1
  79. package/dist/providers/scopus/provider.js.map +1 -1
  80. package/dist/session/types.d.ts +13 -1
  81. package/dist/session/types.d.ts.map +1 -1
  82. package/dist/session/types.js.map +1 -1
  83. package/package.json +1 -1
@@ -58,7 +58,7 @@ export interface ReviewSummary {
58
58
  included: number;
59
59
  /** Articles with finalDecision='exclude' */
60
60
  excluded: number;
61
- /** Articles without finalDecision (pending, incomplete, uncertain, agreed, conflicting) */
61
+ /** Articles without finalDecision (pending, incomplete, all-uncertain, agreed, divided) */
62
62
  pending: number;
63
63
  }
64
64
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"register.js","sources":["../../../src/cli/commands/register.ts"],"sourcesContent":["/**\n * Register command for reference-manager integration.\n * Registers search results with reference-manager CLI.\n */\n\nimport { join } from 'node:path';\nimport { readFile, access } from 'node:fs/promises';\nimport { constants } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport { parse as parseYaml } from 'yaml';\nimport type { ProviderName, Article, Author } from '../../providers/base/types.js';\nimport { parseProviderNames } from '../utils/validation.js';\nimport { classifyStatus, type ReviewFile } from './review/types.js';\n\nexport interface RegisterCommandOptions {\n sessionId: string;\n providers?: ProviderName[];\n dryRun: boolean;\n withAbstracts: boolean;\n /** Register only reviewed articles with finalDecision='include' */\n reviewed?: boolean;\n /** Register all articles, ignoring reviews */\n all?: boolean;\n /** Skip confirmation prompts */\n force?: boolean;\n /** Suppress tips and suggestions */\n quiet?: boolean;\n}\n\nexport interface CommandLineOptions {\n db?: string | undefined;\n dryRun?: boolean | undefined;\n withAbstracts?: boolean | undefined;\n reviewed?: boolean | undefined;\n all?: boolean | undefined;\n force?: boolean | undefined;\n quiet?: boolean | undefined;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Parse command line options into RegisterCommandOptions.\n */\nexport function parseRegisterOptions(\n sessionId: string,\n options: CommandLineOptions\n): RegisterCommandOptions {\n const result: RegisterCommandOptions = {\n sessionId,\n dryRun: options.dryRun ?? false,\n withAbstracts: options.withAbstracts ?? false,\n reviewed: options.reviewed ?? false,\n all: options.all ?? false,\n force: options.force ?? false,\n quiet: options.quiet ?? false,\n };\n\n if (options.db) {\n result.providers = parseProviderNames(options.db);\n }\n\n return result;\n}\n\n/**\n * Validate register command input.\n */\nexport function validateRegisterInput(options: RegisterCommandOptions): ValidationResult {\n if (!options.sessionId || options.sessionId.trim() === '') {\n return {\n valid: false,\n error: 'A session ID is required',\n };\n }\n\n return { valid: true };\n}\n\n/**\n * Format registration summary for CLI output.\n */\nexport function formatRegistrationSummary(summary: {\n total: number;\n added: number;\n skipped: number;\n failed: number;\n noId: number;\n}): string {\n const lines: string[] = ['Registration complete:'];\n\n // Added\n lines.push(` ✓ ${summary.added} added`);\n\n // Duplicates (skipped)\n if (summary.skipped > 0) {\n lines.push(` ⚠ ${summary.skipped} duplicates (already in library)`);\n }\n\n // Failed\n if (summary.failed > 0) {\n lines.push(` ✗ ${summary.failed} failed`);\n }\n\n // No ID (skipped)\n if (summary.noId > 0) {\n lines.push(` - ${summary.noId} skipped (no identifier)`);\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Get registration identifier for an article.\n * PMID is preferred over DOI for better metadata quality.\n */\nfunction getRegistrationId(article: Article): string | null {\n if (article.pmid) {\n return `pmid:${article.pmid}`;\n }\n if (article.doi) {\n return article.doi;\n }\n return null;\n}\n\n/**\n * Format dry run output showing what would be registered.\n */\nexport function formatDryRunOutput(articles: Article[]): string {\n const withId: Array<{ article: Article; id: string }> = [];\n const withoutId: Article[] = [];\n\n for (const article of articles) {\n const id = getRegistrationId(article);\n if (id) {\n withId.push({ article, id });\n } else {\n withoutId.push(article);\n }\n }\n\n const lines: string[] = [];\n\n // Summary\n lines.push(\n `Would register ${withId.length} reference${withId.length !== 1 ? 's' : ''}:`\n );\n\n // List articles with IDs\n for (const { id, article } of withId) {\n const title = article.title.length > 60\n ? article.title.substring(0, 57) + '...'\n : article.title;\n lines.push(` - ${id}: ${title}`);\n }\n\n // Details about articles without DOI/PMID\n if (withoutId.length > 0) {\n lines.push('');\n lines.push(\n `${withoutId.length} article${withoutId.length !== 1 ? 's' : ''} will be skipped (no DOI or PMID):`\n );\n\n const maxDisplay = 10;\n const displayed = withoutId.slice(0, maxDisplay);\n\n for (const article of displayed) {\n const truncatedTitle = article.title.length > 50\n ? article.title.substring(0, 50) + '...'\n : article.title;\n\n const altIds = getAlternativeIds(article);\n const hasAltIds = altIds.length > 0 ? `, has: ${altIds.join(', ')}` : '';\n\n lines.push(` - \"${truncatedTitle}\" (source: ${article.source}${hasAltIds})`);\n }\n\n if (withoutId.length > maxDisplay) {\n lines.push(` ... and ${withoutId.length - maxDisplay} more`);\n }\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Get alternative (non-DOI/PMID) identifiers for an article.\n */\nfunction getAlternativeIds(article: Article): string[] {\n const ids: string[] = [];\n if (article.arxivId) ids.push(`arxiv:${article.arxivId}`);\n if (article.ericId) ids.push(`eric:${article.ericId}`);\n if (article.scopusId) ids.push(`scopus:${article.scopusId}`);\n return ids;\n}\n\n/**\n * Summary of review decisions for a session.\n */\nexport interface ReviewSummary {\n /** Total articles in review file */\n total: number;\n /** Articles with finalDecision='include' */\n included: number;\n /** Articles with finalDecision='exclude' */\n excluded: number;\n /** Articles without finalDecision (pending, incomplete, uncertain, agreed, conflicting) */\n pending: number;\n}\n\n/**\n * Check if a session has a reviews.yaml file.\n */\nexport async function hasReviewFile(sessionId: string, sessionsDir: string): Promise<boolean> {\n const reviewsPath = join(sessionsDir, sessionId, '.internal', 'reviews.yaml');\n try {\n await access(reviewsPath, constants.R_OK);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Load and parse the review file for a session.\n */\nasync function loadReviewFile(sessionId: string, sessionsDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionsDir, sessionId, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Get review summary (counts) for a session.\n * Throws if reviews.yaml does not exist.\n */\nexport async function getReviewSummary(sessionId: string, sessionsDir: string): Promise<ReviewSummary> {\n const reviewFile = await loadReviewFile(sessionId, sessionsDir);\n const articles = reviewFile.articles ?? [];\n\n const summary: ReviewSummary = {\n total: articles.length,\n included: 0,\n excluded: 0,\n pending: 0,\n };\n\n for (const article of articles) {\n const status = classifyStatus(article);\n\n if (status === 'finalized') {\n if (article.finalDecision === 'include') {\n summary.included++;\n } else {\n summary.excluded++;\n }\n } else {\n // pending, incomplete, uncertain, agreed, conflicting all count as pending for registration\n summary.pending++;\n }\n }\n\n return summary;\n}\n\n/**\n * Parse author name string into Author object.\n * Simple heuristic: last word is family name, rest is given name.\n */\nfunction parseAuthorName(name: string): Author {\n const parts = name.trim().split(/\\s+/);\n if (parts.length === 1) {\n return { family: parts[0] ?? '' };\n }\n // Last part is family name (most common pattern in scientific citations)\n const family = parts.pop() ?? '';\n const given = parts.join(' ');\n return { family, given };\n}\n\n/**\n * Get articles with finalDecision='include' from review file.\n * Converts from ArticleEntry format to Article format.\n *\n * @throws Error if mergedFrom is missing or empty (indicates legacy review file)\n */\nexport async function getIncludedArticles(sessionId: string, sessionsDir: string): Promise<Article[]> {\n const reviewFile = await loadReviewFile(sessionId, sessionsDir);\n const articles = reviewFile.articles ?? [];\n\n return articles\n .filter((entry) => entry.finalDecision === 'include')\n .map((entry): Article => {\n // Validate mergedFrom exists\n if (!entry.mergedFrom) {\n throw new Error(\n `Article \"${entry.title}\" has mergedFrom missing. ` +\n `This may be a legacy review file created before source tracking was fixed. ` +\n `Please re-run 'review init' to regenerate the review file with source tracking.`\n );\n }\n if (entry.mergedFrom.length === 0) {\n throw new Error(\n `Article \"${entry.title}\" has empty mergedFrom array. ` +\n `This is an invalid state - please re-run 'review init' to regenerate.`\n );\n }\n\n const authors: Author[] = entry.authors\n ? entry.authors.split(/,\\s*/).map(parseAuthorName)\n : [];\n\n // Get source from the first entry in mergedFrom\n const source = entry.mergedFrom[0]!.source as ProviderName;\n\n const article: Article = {\n title: entry.title,\n authors,\n source,\n retrievedAt: new Date().toISOString(),\n };\n // Only set optional fields if they have values\n if (entry.doi) article.doi = entry.doi;\n if (entry.pmid) article.pmid = entry.pmid;\n if (entry.scopusId) article.scopusId = entry.scopusId;\n if (entry.arxivId) article.arxivId = entry.arxivId;\n if (entry.ericId) article.ericId = entry.ericId;\n if (entry.abstract) article.abstract = entry.abstract;\n if (entry.year) article.publicationDate = entry.year;\n return article;\n });\n}\n\n/**\n * Format message when reviews exist but no flag specified.\n */\nexport function formatReviewRequiredMessage(summary: ReviewSummary, sessionId: string): string {\n return `This session has a review file.\n Status: ${summary.included} include / ${summary.excluded} exclude / ${summary.pending} pending\n\nPlease specify which articles to register:\n --reviewed Register ${summary.included} included articles\n --all Register all ${summary.total} articles (ignore reviews)\n\nExample:\n search-hub register ${sessionId} --reviewed`;\n}\n\n/**\n * Format error when --reviewed used but no articles are included.\n */\nexport function formatNoIncludedArticlesError(summary: ReviewSummary, sessionId: string): string {\n return `Error: No articles marked as 'include' in reviews.\n Status: ${summary.included} include / ${summary.excluded} exclude / ${summary.pending} pending\n\nRun 'search-hub review status ${sessionId}' for details.`;\n}\n\n/**\n * Format warning when pending articles exist with --reviewed.\n */\nexport function formatPendingWarning(summary: ReviewSummary): string {\n const articleWord = summary.pending === 1 ? 'article' : 'articles';\n return `Warning: ${summary.pending} ${articleWord} still pending review (will be skipped).\nRegistering ${summary.included} included articles...\n\nProceed? [Y/n]`;\n}\n\n\n/**\n * Format note when --all is used with reviews.yaml present.\n */\nexport function formatIgnoringReviewsNote(total: number): string {\n return `Note: Ignoring review decisions. Registering all ${total} articles.`;\n}\n\n/**\n * Prompt user for Y/n confirmation.\n * Returns true if user confirms (Y/y/Enter), false otherwise.\n */\nexport async function confirmPrompt(\n input: NodeJS.ReadableStream = process.stdin,\n output: NodeJS.WritableStream = process.stdout\n): Promise<boolean> {\n const rl = createInterface({\n input,\n output,\n terminal: false,\n });\n\n return new Promise((resolve) => {\n rl.question('', (answer) => {\n rl.close();\n const trimmed = answer.trim().toLowerCase();\n // Empty (Enter) or 'y' or 'yes' means confirm\n resolve(trimmed === '' || trimmed === 'y' || trimmed === 'yes');\n });\n });\n}\n"],"names":["parseYaml"],"mappings":"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"}
1
+ {"version":3,"file":"register.js","sources":["../../../src/cli/commands/register.ts"],"sourcesContent":["/**\n * Register command for reference-manager integration.\n * Registers search results with reference-manager CLI.\n */\n\nimport { join } from 'node:path';\nimport { readFile, access } from 'node:fs/promises';\nimport { constants } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport { parse as parseYaml } from 'yaml';\nimport type { ProviderName, Article, Author } from '../../providers/base/types.js';\nimport { parseProviderNames } from '../utils/validation.js';\nimport { classifyStatus, type ReviewFile } from './review/types.js';\n\nexport interface RegisterCommandOptions {\n sessionId: string;\n providers?: ProviderName[];\n dryRun: boolean;\n withAbstracts: boolean;\n /** Register only reviewed articles with finalDecision='include' */\n reviewed?: boolean;\n /** Register all articles, ignoring reviews */\n all?: boolean;\n /** Skip confirmation prompts */\n force?: boolean;\n /** Suppress tips and suggestions */\n quiet?: boolean;\n}\n\nexport interface CommandLineOptions {\n db?: string | undefined;\n dryRun?: boolean | undefined;\n withAbstracts?: boolean | undefined;\n reviewed?: boolean | undefined;\n all?: boolean | undefined;\n force?: boolean | undefined;\n quiet?: boolean | undefined;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Parse command line options into RegisterCommandOptions.\n */\nexport function parseRegisterOptions(\n sessionId: string,\n options: CommandLineOptions\n): RegisterCommandOptions {\n const result: RegisterCommandOptions = {\n sessionId,\n dryRun: options.dryRun ?? false,\n withAbstracts: options.withAbstracts ?? false,\n reviewed: options.reviewed ?? false,\n all: options.all ?? false,\n force: options.force ?? false,\n quiet: options.quiet ?? false,\n };\n\n if (options.db) {\n result.providers = parseProviderNames(options.db);\n }\n\n return result;\n}\n\n/**\n * Validate register command input.\n */\nexport function validateRegisterInput(options: RegisterCommandOptions): ValidationResult {\n if (!options.sessionId || options.sessionId.trim() === '') {\n return {\n valid: false,\n error: 'A session ID is required',\n };\n }\n\n return { valid: true };\n}\n\n/**\n * Format registration summary for CLI output.\n */\nexport function formatRegistrationSummary(summary: {\n total: number;\n added: number;\n skipped: number;\n failed: number;\n noId: number;\n}): string {\n const lines: string[] = ['Registration complete:'];\n\n // Added\n lines.push(` ✓ ${summary.added} added`);\n\n // Duplicates (skipped)\n if (summary.skipped > 0) {\n lines.push(` ⚠ ${summary.skipped} duplicates (already in library)`);\n }\n\n // Failed\n if (summary.failed > 0) {\n lines.push(` ✗ ${summary.failed} failed`);\n }\n\n // No ID (skipped)\n if (summary.noId > 0) {\n lines.push(` - ${summary.noId} skipped (no identifier)`);\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Get registration identifier for an article.\n * PMID is preferred over DOI for better metadata quality.\n */\nfunction getRegistrationId(article: Article): string | null {\n if (article.pmid) {\n return `pmid:${article.pmid}`;\n }\n if (article.doi) {\n return article.doi;\n }\n return null;\n}\n\n/**\n * Format dry run output showing what would be registered.\n */\nexport function formatDryRunOutput(articles: Article[]): string {\n const withId: Array<{ article: Article; id: string }> = [];\n const withoutId: Article[] = [];\n\n for (const article of articles) {\n const id = getRegistrationId(article);\n if (id) {\n withId.push({ article, id });\n } else {\n withoutId.push(article);\n }\n }\n\n const lines: string[] = [];\n\n // Summary\n lines.push(\n `Would register ${withId.length} reference${withId.length !== 1 ? 's' : ''}:`\n );\n\n // List articles with IDs\n for (const { id, article } of withId) {\n const title = article.title.length > 60\n ? article.title.substring(0, 57) + '...'\n : article.title;\n lines.push(` - ${id}: ${title}`);\n }\n\n // Details about articles without DOI/PMID\n if (withoutId.length > 0) {\n lines.push('');\n lines.push(\n `${withoutId.length} article${withoutId.length !== 1 ? 's' : ''} will be skipped (no DOI or PMID):`\n );\n\n const maxDisplay = 10;\n const displayed = withoutId.slice(0, maxDisplay);\n\n for (const article of displayed) {\n const truncatedTitle = article.title.length > 50\n ? article.title.substring(0, 50) + '...'\n : article.title;\n\n const altIds = getAlternativeIds(article);\n const hasAltIds = altIds.length > 0 ? `, has: ${altIds.join(', ')}` : '';\n\n lines.push(` - \"${truncatedTitle}\" (source: ${article.source}${hasAltIds})`);\n }\n\n if (withoutId.length > maxDisplay) {\n lines.push(` ... and ${withoutId.length - maxDisplay} more`);\n }\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Get alternative (non-DOI/PMID) identifiers for an article.\n */\nfunction getAlternativeIds(article: Article): string[] {\n const ids: string[] = [];\n if (article.arxivId) ids.push(`arxiv:${article.arxivId}`);\n if (article.ericId) ids.push(`eric:${article.ericId}`);\n if (article.scopusId) ids.push(`scopus:${article.scopusId}`);\n return ids;\n}\n\n/**\n * Summary of review decisions for a session.\n */\nexport interface ReviewSummary {\n /** Total articles in review file */\n total: number;\n /** Articles with finalDecision='include' */\n included: number;\n /** Articles with finalDecision='exclude' */\n excluded: number;\n /** Articles without finalDecision (pending, incomplete, all-uncertain, agreed, divided) */\n pending: number;\n}\n\n/**\n * Check if a session has a reviews.yaml file.\n */\nexport async function hasReviewFile(sessionId: string, sessionsDir: string): Promise<boolean> {\n const reviewsPath = join(sessionsDir, sessionId, '.internal', 'reviews.yaml');\n try {\n await access(reviewsPath, constants.R_OK);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Load and parse the review file for a session.\n */\nasync function loadReviewFile(sessionId: string, sessionsDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionsDir, sessionId, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Get review summary (counts) for a session.\n * Throws if reviews.yaml does not exist.\n */\nexport async function getReviewSummary(sessionId: string, sessionsDir: string): Promise<ReviewSummary> {\n const reviewFile = await loadReviewFile(sessionId, sessionsDir);\n const articles = reviewFile.articles ?? [];\n\n const summary: ReviewSummary = {\n total: articles.length,\n included: 0,\n excluded: 0,\n pending: 0,\n };\n\n for (const article of articles) {\n const status = classifyStatus(article);\n\n if (status === 'finalized') {\n if (article.finalDecision === 'include') {\n summary.included++;\n } else {\n summary.excluded++;\n }\n } else {\n // pending, incomplete, all-uncertain, agreed, divided all count as pending for registration\n summary.pending++;\n }\n }\n\n return summary;\n}\n\n/**\n * Parse author name string into Author object.\n * Simple heuristic: last word is family name, rest is given name.\n */\nfunction parseAuthorName(name: string): Author {\n const parts = name.trim().split(/\\s+/);\n if (parts.length === 1) {\n return { family: parts[0] ?? '' };\n }\n // Last part is family name (most common pattern in scientific citations)\n const family = parts.pop() ?? '';\n const given = parts.join(' ');\n return { family, given };\n}\n\n/**\n * Get articles with finalDecision='include' from review file.\n * Converts from ArticleEntry format to Article format.\n *\n * @throws Error if mergedFrom is missing or empty (indicates legacy review file)\n */\nexport async function getIncludedArticles(sessionId: string, sessionsDir: string): Promise<Article[]> {\n const reviewFile = await loadReviewFile(sessionId, sessionsDir);\n const articles = reviewFile.articles ?? [];\n\n return articles\n .filter((entry) => entry.finalDecision === 'include')\n .map((entry): Article => {\n // Validate mergedFrom exists\n if (!entry.mergedFrom) {\n throw new Error(\n `Article \"${entry.title}\" has mergedFrom missing. ` +\n `This may be a legacy review file created before source tracking was fixed. ` +\n `Please re-run 'review init' to regenerate the review file with source tracking.`\n );\n }\n if (entry.mergedFrom.length === 0) {\n throw new Error(\n `Article \"${entry.title}\" has empty mergedFrom array. ` +\n `This is an invalid state - please re-run 'review init' to regenerate.`\n );\n }\n\n const authors: Author[] = entry.authors\n ? entry.authors.split(/,\\s*/).map(parseAuthorName)\n : [];\n\n // Get source from the first entry in mergedFrom\n const source = entry.mergedFrom[0]!.source as ProviderName;\n\n const article: Article = {\n title: entry.title,\n authors,\n source,\n retrievedAt: new Date().toISOString(),\n };\n // Only set optional fields if they have values\n if (entry.doi) article.doi = entry.doi;\n if (entry.pmid) article.pmid = entry.pmid;\n if (entry.scopusId) article.scopusId = entry.scopusId;\n if (entry.arxivId) article.arxivId = entry.arxivId;\n if (entry.ericId) article.ericId = entry.ericId;\n if (entry.abstract) article.abstract = entry.abstract;\n if (entry.year) article.publicationDate = entry.year;\n return article;\n });\n}\n\n/**\n * Format message when reviews exist but no flag specified.\n */\nexport function formatReviewRequiredMessage(summary: ReviewSummary, sessionId: string): string {\n return `This session has a review file.\n Status: ${summary.included} include / ${summary.excluded} exclude / ${summary.pending} pending\n\nPlease specify which articles to register:\n --reviewed Register ${summary.included} included articles\n --all Register all ${summary.total} articles (ignore reviews)\n\nExample:\n search-hub register ${sessionId} --reviewed`;\n}\n\n/**\n * Format error when --reviewed used but no articles are included.\n */\nexport function formatNoIncludedArticlesError(summary: ReviewSummary, sessionId: string): string {\n return `Error: No articles marked as 'include' in reviews.\n Status: ${summary.included} include / ${summary.excluded} exclude / ${summary.pending} pending\n\nRun 'search-hub review status ${sessionId}' for details.`;\n}\n\n/**\n * Format warning when pending articles exist with --reviewed.\n */\nexport function formatPendingWarning(summary: ReviewSummary): string {\n const articleWord = summary.pending === 1 ? 'article' : 'articles';\n return `Warning: ${summary.pending} ${articleWord} still pending review (will be skipped).\nRegistering ${summary.included} included articles...\n\nProceed? [Y/n]`;\n}\n\n\n/**\n * Format note when --all is used with reviews.yaml present.\n */\nexport function formatIgnoringReviewsNote(total: number): string {\n return `Note: Ignoring review decisions. Registering all ${total} articles.`;\n}\n\n/**\n * Prompt user for Y/n confirmation.\n * Returns true if user confirms (Y/y/Enter), false otherwise.\n */\nexport async function confirmPrompt(\n input: NodeJS.ReadableStream = process.stdin,\n output: NodeJS.WritableStream = process.stdout\n): Promise<boolean> {\n const rl = createInterface({\n input,\n output,\n terminal: false,\n });\n\n return new Promise((resolve) => {\n rl.question('', (answer) => {\n rl.close();\n const trimmed = answer.trim().toLowerCase();\n // Empty (Enter) or 'y' or 'yes' means confirm\n resolve(trimmed === '' || trimmed === 'y' || trimmed === 'yes');\n });\n });\n}\n"],"names":["parseYaml"],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ import { Article } from '../../providers/base/types.js';
2
+ import { SessionFile, SessionSeeds } from '../../session/types.js';
3
+ /**
4
+ * Parsed options for the related command.
5
+ */
6
+ export interface RelatedCommandOptions {
7
+ pmids: string[];
8
+ name?: string;
9
+ maxResults: number;
10
+ fromSession?: string;
11
+ term?: string;
12
+ }
13
+ /**
14
+ * CLI option types from Commander.js.
15
+ */
16
+ export interface CommandLineOptions {
17
+ name?: string;
18
+ maxResults?: string;
19
+ fromSession?: string;
20
+ pmid?: string | string[];
21
+ term?: string;
22
+ }
23
+ export interface ValidationResult {
24
+ valid: boolean;
25
+ error?: string;
26
+ }
27
+ /**
28
+ * Options for creating a related session.
29
+ */
30
+ export interface CreateRelatedSessionOptions {
31
+ name: string;
32
+ seeds: SessionSeeds;
33
+ articles: Article[];
34
+ sessionsDir: string;
35
+ }
36
+ /**
37
+ * Data for formatting related output.
38
+ */
39
+ export interface RelatedOutputData {
40
+ sessionId: string;
41
+ seedCount: number;
42
+ totalRelated: number;
43
+ retrievedCount: number;
44
+ articles: Article[];
45
+ }
46
+ /**
47
+ * Parse command line options into RelatedCommandOptions.
48
+ */
49
+ export declare function parseRelatedOptions(pmidArgs: string[], options: CommandLineOptions): RelatedCommandOptions;
50
+ /**
51
+ * Validate related command input.
52
+ */
53
+ export declare function validateRelatedInput(options: RelatedCommandOptions): ValidationResult;
54
+ /**
55
+ * Resolve seed PMIDs from options and/or existing session.
56
+ */
57
+ export declare function resolveSeeds(options: RelatedCommandOptions, sessionsDir: string): Promise<string[]>;
58
+ /**
59
+ * Create a related session on disk.
60
+ */
61
+ export declare function createRelatedSession(options: CreateRelatedSessionOptions): Promise<SessionFile>;
62
+ /**
63
+ * Format related search output for display.
64
+ */
65
+ export declare function formatRelatedOutput(data: RelatedOutputData): string;
66
+ //# sourceMappingURL=related.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"related.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/related.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAOxE;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,EAAE,kBAAkB,GAC1B,qBAAqB,CA6BvB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,gBAAgB,CA0BrF;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CA+BnB;AAYD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,2BAA2B,GACnC,OAAO,CAAC,WAAW,CAAC,CAgEtB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA0BnE"}
@@ -0,0 +1,161 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { stringify } from "yaml";
5
+ import { loadSession, sanitizeName } from "../../session/manager.js";
6
+ import { convertResultsToYaml } from "../../session/results-io.js";
7
+ import { loadSessionArticles } from "./session-utils.js";
8
+ function parseRelatedOptions(pmidArgs, options) {
9
+ const result = {
10
+ pmids: [...pmidArgs],
11
+ maxResults: 20
12
+ };
13
+ if (options.pmid) {
14
+ const pmidOption = Array.isArray(options.pmid) ? options.pmid : [options.pmid];
15
+ result.pmids = [...result.pmids, ...pmidOption];
16
+ }
17
+ if (options.name) {
18
+ result.name = options.name;
19
+ }
20
+ if (options.maxResults) {
21
+ result.maxResults = parseInt(options.maxResults, 10);
22
+ }
23
+ if (options.fromSession) {
24
+ result.fromSession = options.fromSession;
25
+ }
26
+ if (options.term) {
27
+ result.term = options.term;
28
+ }
29
+ return result;
30
+ }
31
+ function validateRelatedInput(options) {
32
+ if (options.pmids.length === 0 && !options.fromSession) {
33
+ return {
34
+ valid: false,
35
+ error: "At least one PMID is required. Provide PMIDs as arguments or use --from-session."
36
+ };
37
+ }
38
+ for (const pmid of options.pmids) {
39
+ if (!/^\d+$/.test(pmid)) {
40
+ return {
41
+ valid: false,
42
+ error: `Invalid PMID format: "${pmid}". PMIDs must be numeric.`
43
+ };
44
+ }
45
+ }
46
+ if (options.maxResults <= 0) {
47
+ return {
48
+ valid: false,
49
+ error: "--max-results must be a positive number."
50
+ };
51
+ }
52
+ return { valid: true };
53
+ }
54
+ async function resolveSeeds(options, sessionsDir) {
55
+ if (!options.fromSession) {
56
+ return options.pmids;
57
+ }
58
+ const session = await loadSession(options.fromSession, sessionsDir);
59
+ const articles = await loadSessionArticles(session, options.fromSession, sessionsDir);
60
+ const sessionPmids = /* @__PURE__ */ new Set();
61
+ for (const article of articles) {
62
+ if (article.pmid) {
63
+ sessionPmids.add(article.pmid);
64
+ }
65
+ }
66
+ if (options.pmids.length === 0) {
67
+ return [...sessionPmids];
68
+ }
69
+ const missing = options.pmids.filter((pmid) => !sessionPmids.has(pmid));
70
+ if (missing.length > 0) {
71
+ throw new Error(
72
+ `PMIDs not found in session "${options.fromSession}": ${missing.join(", ")}`
73
+ );
74
+ }
75
+ return options.pmids;
76
+ }
77
+ function generateRelatedSessionId(name, seeds) {
78
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "");
79
+ const sanitized = sanitizeName(name);
80
+ const hash = createHash("sha256").update(seeds.join(",")).digest("hex").slice(0, 6);
81
+ return `${date}_${sanitized}_${hash}`;
82
+ }
83
+ async function createRelatedSession(options) {
84
+ const { name, seeds, articles, sessionsDir } = options;
85
+ const id = generateRelatedSessionId(name, seeds.ids);
86
+ const sessionDir = join(sessionsDir, id);
87
+ const now = (/* @__PURE__ */ new Date()).toISOString();
88
+ await mkdir(sessionDir, { recursive: true });
89
+ const jsonlFilename = "pubmed_results.jsonl";
90
+ const yamlFilename = "pubmed_results.yaml";
91
+ const jsonlPath = join(sessionDir, jsonlFilename);
92
+ const jsonlContent = articles.map((a) => JSON.stringify(a)).join("\n") + "\n";
93
+ await writeFile(jsonlPath, jsonlContent, "utf-8");
94
+ const yamlPath = join(sessionDir, yamlFilename);
95
+ await convertResultsToYaml(jsonlPath, yamlPath, {
96
+ provider: "pubmed",
97
+ queryName: name
98
+ });
99
+ const databases = {
100
+ pubmed: {
101
+ status: "completed",
102
+ retrievedCount: articles.length,
103
+ files: {
104
+ query: "",
105
+ results: jsonlFilename,
106
+ resultsYaml: yamlFilename
107
+ }
108
+ }
109
+ };
110
+ const sessionFile = {
111
+ version: 1,
112
+ id,
113
+ name,
114
+ type: "related",
115
+ createdAt: now,
116
+ updatedAt: now,
117
+ seeds,
118
+ databases,
119
+ summary: {
120
+ totalHits: 0,
121
+ totalRetrieved: articles.length,
122
+ status: "completed"
123
+ }
124
+ };
125
+ await writeFile(
126
+ join(sessionDir, "session.yaml"),
127
+ stringify(sessionFile),
128
+ "utf-8"
129
+ );
130
+ return sessionFile;
131
+ }
132
+ function formatRelatedOutput(data) {
133
+ const lines = [];
134
+ lines.push(`Related session: ${data.sessionId}`);
135
+ lines.push("");
136
+ lines.push(`Seeds: ${data.seedCount} PMIDs`);
137
+ lines.push(`Related found: ${data.totalRelated}`);
138
+ lines.push(`Retrieved: ${data.retrievedCount}`);
139
+ if (data.articles.length > 0) {
140
+ lines.push("");
141
+ lines.push("Top results:");
142
+ const maxDisplay = Math.min(data.articles.length, 10);
143
+ for (let i = 0; i < maxDisplay; i++) {
144
+ const article = data.articles[i];
145
+ const title = article.title.length > 70 ? article.title.substring(0, 67) + "..." : article.title;
146
+ lines.push(` ${i + 1}. ${title}`);
147
+ }
148
+ if (data.articles.length > maxDisplay) {
149
+ lines.push(` ... and ${data.articles.length - maxDisplay} more`);
150
+ }
151
+ }
152
+ return lines.join("\n");
153
+ }
154
+ export {
155
+ createRelatedSession,
156
+ formatRelatedOutput,
157
+ parseRelatedOptions,
158
+ resolveSeeds,
159
+ validateRelatedInput
160
+ };
161
+ //# sourceMappingURL=related.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"related.js","sources":["../../../src/cli/commands/related.ts"],"sourcesContent":["/**\n * Related command for finding related articles via PubMed ELink.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport { stringify as stringifyYaml } from 'yaml';\nimport type { Article } from '../../providers/base/types.js';\nimport type { SessionFile, SessionSeeds } from '../../session/types.js';\nimport type { DatabaseStatus } from '../../session/types.js';\nimport { loadSession } from '../../session/manager.js';\nimport { sanitizeName } from '../../session/manager.js';\nimport { convertResultsToYaml } from '../../session/results-io.js';\nimport { loadSessionArticles } from './session-utils.js';\n\n/**\n * Parsed options for the related command.\n */\nexport interface RelatedCommandOptions {\n pmids: string[];\n name?: string;\n maxResults: number;\n fromSession?: string;\n term?: string;\n}\n\n/**\n * CLI option types from Commander.js.\n */\nexport interface CommandLineOptions {\n name?: string;\n maxResults?: string;\n fromSession?: string;\n pmid?: string | string[];\n term?: string;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Options for creating a related session.\n */\nexport interface CreateRelatedSessionOptions {\n name: string;\n seeds: SessionSeeds;\n articles: Article[];\n sessionsDir: string;\n}\n\n/**\n * Data for formatting related output.\n */\nexport interface RelatedOutputData {\n sessionId: string;\n seedCount: number;\n totalRelated: number;\n retrievedCount: number;\n articles: Article[];\n}\n\n/**\n * Parse command line options into RelatedCommandOptions.\n */\nexport function parseRelatedOptions(\n pmidArgs: string[],\n options: CommandLineOptions,\n): RelatedCommandOptions {\n const result: RelatedCommandOptions = {\n pmids: [...pmidArgs],\n maxResults: 20,\n };\n\n // --pmid option (alternative to positional args, required with --from-session)\n if (options.pmid) {\n const pmidOption = Array.isArray(options.pmid) ? options.pmid : [options.pmid];\n result.pmids = [...result.pmids, ...pmidOption];\n }\n\n if (options.name) {\n result.name = options.name;\n }\n\n if (options.maxResults) {\n result.maxResults = parseInt(options.maxResults, 10);\n }\n\n if (options.fromSession) {\n result.fromSession = options.fromSession;\n }\n\n if (options.term) {\n result.term = options.term;\n }\n\n return result;\n}\n\n/**\n * Validate related command input.\n */\nexport function validateRelatedInput(options: RelatedCommandOptions): ValidationResult {\n if (options.pmids.length === 0 && !options.fromSession) {\n return {\n valid: false,\n error: 'At least one PMID is required. Provide PMIDs as arguments or use --from-session.',\n };\n }\n\n // Validate PMID format (numeric strings)\n for (const pmid of options.pmids) {\n if (!/^\\d+$/.test(pmid)) {\n return {\n valid: false,\n error: `Invalid PMID format: \"${pmid}\". PMIDs must be numeric.`,\n };\n }\n }\n\n if (options.maxResults <= 0) {\n return {\n valid: false,\n error: '--max-results must be a positive number.',\n };\n }\n\n return { valid: true };\n}\n\n/**\n * Resolve seed PMIDs from options and/or existing session.\n */\nexport async function resolveSeeds(\n options: RelatedCommandOptions,\n sessionsDir: string,\n): Promise<string[]> {\n if (!options.fromSession) {\n return options.pmids;\n }\n\n // Load articles from the source session\n const session = await loadSession(options.fromSession, sessionsDir);\n const articles = await loadSessionArticles(session, options.fromSession, sessionsDir);\n\n // Extract PMIDs from session articles\n const sessionPmids = new Set<string>();\n for (const article of articles) {\n if (article.pmid) {\n sessionPmids.add(article.pmid);\n }\n }\n\n if (options.pmids.length === 0) {\n // No specific PMIDs requested, return all from session\n return [...sessionPmids];\n }\n\n // Validate that requested PMIDs exist in the session\n const missing = options.pmids.filter(pmid => !sessionPmids.has(pmid));\n if (missing.length > 0) {\n throw new Error(\n `PMIDs not found in session \"${options.fromSession}\": ${missing.join(', ')}`\n );\n }\n\n return options.pmids;\n}\n\n/**\n * Generate a session ID for a related session.\n */\nfunction generateRelatedSessionId(name: string, seeds: string[]): string {\n const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');\n const sanitized = sanitizeName(name);\n const hash = createHash('sha256').update(seeds.join(',')).digest('hex').slice(0, 6);\n return `${date}_${sanitized}_${hash}`;\n}\n\n/**\n * Create a related session on disk.\n */\nexport async function createRelatedSession(\n options: CreateRelatedSessionOptions,\n): Promise<SessionFile> {\n const { name, seeds, articles, sessionsDir } = options;\n\n const id = generateRelatedSessionId(name, seeds.ids);\n const sessionDir = join(sessionsDir, id);\n const now = new Date().toISOString();\n\n await mkdir(sessionDir, { recursive: true });\n\n // Write JSONL results (use {provider}_results convention for loadResults compatibility)\n const jsonlFilename = 'pubmed_results.jsonl';\n const yamlFilename = 'pubmed_results.yaml';\n const jsonlPath = join(sessionDir, jsonlFilename);\n\n const jsonlContent = articles\n .map((a) => JSON.stringify(a))\n .join('\\n') + '\\n';\n await writeFile(jsonlPath, jsonlContent, 'utf-8');\n\n // Convert to YAML\n const yamlPath = join(sessionDir, yamlFilename);\n await convertResultsToYaml(jsonlPath, yamlPath, {\n provider: 'pubmed',\n queryName: name,\n });\n\n // Build database status\n const databases: Partial<Record<string, DatabaseStatus>> = {\n pubmed: {\n status: 'completed',\n retrievedCount: articles.length,\n files: {\n query: '',\n results: jsonlFilename,\n resultsYaml: yamlFilename,\n },\n },\n };\n\n // Create session file\n const sessionFile: SessionFile = {\n version: 1,\n id,\n name,\n type: 'related',\n createdAt: now,\n updatedAt: now,\n seeds,\n databases,\n summary: {\n totalHits: 0,\n totalRetrieved: articles.length,\n status: 'completed',\n },\n };\n\n // Write session.yaml\n await writeFile(\n join(sessionDir, 'session.yaml'),\n stringifyYaml(sessionFile),\n 'utf-8',\n );\n\n return sessionFile;\n}\n\n/**\n * Format related search output for display.\n */\nexport function formatRelatedOutput(data: RelatedOutputData): string {\n const lines: string[] = [];\n\n lines.push(`Related session: ${data.sessionId}`);\n lines.push('');\n lines.push(`Seeds: ${data.seedCount} PMIDs`);\n lines.push(`Related found: ${data.totalRelated}`);\n lines.push(`Retrieved: ${data.retrievedCount}`);\n\n if (data.articles.length > 0) {\n lines.push('');\n lines.push('Top results:');\n const maxDisplay = Math.min(data.articles.length, 10);\n for (let i = 0; i < maxDisplay; i++) {\n const article = data.articles[i]!;\n const title = article.title.length > 70\n ? article.title.substring(0, 67) + '...'\n : article.title;\n lines.push(` ${i + 1}. ${title}`);\n }\n if (data.articles.length > maxDisplay) {\n lines.push(` ... and ${data.articles.length - maxDisplay} more`);\n }\n }\n\n return lines.join('\\n');\n}\n"],"names":["stringifyYaml"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAErH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAGD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAgLD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,EAC7B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CAkH9B"}
1
+ {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,EAAmB,MAAM,YAAY,CAAC;AAEtI,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAGD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAyLD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,EAC7B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CAqH9B"}
@@ -20,10 +20,19 @@ function seededShuffle(array, seed) {
20
20
  }
21
21
  return result;
22
22
  }
23
- function getBasisGuidanceComment(basis) {
23
+ function getBasisGuidanceComment(basis, mode = "screening") {
24
24
  const schemaLine = "# yaml-language-server: $schema=./review.schema.json";
25
25
  switch (basis) {
26
26
  case "title":
27
+ if (mode === "picking") {
28
+ return [
29
+ schemaLine,
30
+ "# Review by title only.",
31
+ '# Mark relevant items as "include" with a comment explaining the reason.',
32
+ '# Leave everything else as "uncertain".',
33
+ ""
34
+ ].join("\n");
35
+ }
27
36
  return [
28
37
  schemaLine,
29
38
  "# Screening by title only.",
@@ -49,10 +58,10 @@ function getBasisGuidanceComment(basis) {
49
58
  ].join("\n");
50
59
  }
51
60
  }
52
- function getDecisionInlineComment(basis) {
61
+ function getDecisionInlineComment(basis, mode = "screening") {
53
62
  switch (basis) {
54
63
  case "title":
55
- return "# exclude / uncertain";
64
+ return mode === "picking" ? "# include / uncertain" : "# exclude / uncertain";
56
65
  case "abstract":
57
66
  case "fulltext":
58
67
  return "# include / exclude / uncertain";
@@ -135,6 +144,7 @@ async function executeReviewExtract(options, sessionsDir) {
135
144
  const sessionDir = join(sessionsDir, options.sessionId);
136
145
  const outputPath = join(sessionDir, "for-review", options.name, "review.yaml");
137
146
  const reviewFile = await loadReviewFile(sessionDir);
147
+ const mode = reviewFile.mode ?? "screening";
138
148
  const reviewers = reviewFile.reviewers;
139
149
  let filtered;
140
150
  if (options.filter && options.filter.length > 0) {
@@ -166,7 +176,7 @@ async function executeReviewExtract(options, sessionsDir) {
166
176
  articles: paginated.map((article) => buildScreeningArticle(article, options.basis))
167
177
  };
168
178
  const yamlContent = stringify(outputFile, { lineWidth: 0 });
169
- const decisionComment = getDecisionInlineComment(options.basis);
179
+ const decisionComment = getDecisionInlineComment(options.basis, mode);
170
180
  let yamlWithComments = yamlContent.replace(
171
181
  /^(\s*-?\s*)decision: uncertain$/gm,
172
182
  `$1decision: uncertain ${decisionComment}`
@@ -175,7 +185,7 @@ async function executeReviewExtract(options, sessionsDir) {
175
185
  /^(\s*)comment: ""$/gm,
176
186
  '$1comment: "" # reason for decision'
177
187
  );
178
- const guidanceComment = getBasisGuidanceComment(options.basis);
188
+ const guidanceComment = getBasisGuidanceComment(options.basis, mode);
179
189
  finalContent = guidanceComment + yamlWithComments;
180
190
  } else {
181
191
  const outputFile = {
@@ -1 +1 @@
1
- {"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n /** When true, outputs final decision format with reviewHistory and finalDecision fields. */\n finalize?: boolean;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\nfunction getBasisGuidanceComment(basis: ReviewBasis): string {\n const schemaLine = '# yaml-language-server: $schema=./review.schema.json';\n switch (basis) {\n case 'title':\n return [\n schemaLine,\n '# Screening by title only.',\n '# Mark clearly irrelevant items as \"exclude\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n case 'abstract':\n return [\n schemaLine,\n '# Screening by title and abstract.',\n '# You should be able to decide \"include\" or \"exclude\" for most items at this stage.',\n '# Mark remaining ambiguous items as \"uncertain\" with a comment explaining why.',\n '',\n ].join('\\n');\n case 'fulltext':\n return [\n schemaLine,\n '# Screening by full text. This is the final decision stage.',\n '# Decide \"include\" or \"exclude\" for each item.',\n '# Use \"uncertain\" only when absolutely unavoidable, with a comment explaining why.',\n '',\n ].join('\\n');\n }\n}\n\nfunction getDecisionInlineComment(basis: ReviewBasis): string {\n switch (basis) {\n case 'title':\n return '# exclude / uncertain';\n case 'abstract':\n case 'fulltext':\n return '# include / exclude / uncertain';\n }\n}\n\n/** Build a screening article for --basis mode: only include fields relevant to the basis */\nfunction buildScreeningArticle(article: ArticleEntry, basis: ReviewBasis): ArticleEntry {\n // Start with identifiers\n const result: ArticleEntry = { title: article.title, reviews: [] };\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Include abstract for abstract and fulltext basis\n if ((basis === 'abstract' || basis === 'fulltext') && article.abstract) {\n result.abstract = article.abstract;\n }\n\n // Include fulltext ref for fulltext basis\n if (basis === 'fulltext' && article.fulltext) {\n result.fulltext = article.fulltext;\n }\n\n // Pre-populate reviews (reviewer omitted; filled from top-level field on merge)\n result.reviews = [{ decision: 'uncertain' as const, comment: '' } as ArticleEntry['reviews'][0]];\n\n return result;\n}\n\n/** Build a finalize article with reviewHistory and finalDecision, optionally scoped by basis */\nfunction buildFinalizeArticle(article: ArticleEntry, basis?: ReviewBasis): ArticleEntry {\n const result: ArticleEntry = { title: article.title, reviews: [] };\n\n // Always include identifiers\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Always include bibliographic metadata\n if (article.authors) result.authors = article.authors;\n if (article.year) result.year = article.year;\n\n // Scope content by basis (or include all if no basis)\n if (!basis || basis === 'abstract' || basis === 'fulltext') {\n if (article.abstract) result.abstract = article.abstract;\n }\n if (!basis || basis === 'fulltext') {\n if (article.fulltext) result.fulltext = article.fulltext;\n }\n\n // Add reviewHistory (existing reviews, read-only)\n result.reviewHistory = article.reviews ?? [];\n\n // Empty reviews for new reviews\n result.reviews = [];\n\n // Null finalDecision as placeholder\n result.finalDecision = null;\n\n return result;\n}\n\nfunction getFinalDecisionGuidanceComment(): string {\n return [\n '# yaml-language-server: $schema=./review.schema.json',\n '# Final decision file: set finalDecision on each article',\n '# Valid decisions: include / exclude / null',\n '',\n ].join('\\n');\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n\n let finalContent: string;\n\n if (options.basis && !options.finalize) {\n // Screening mode: basis-scoped content with pre-populated reviews\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildScreeningArticle(article, options.basis!)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Add decision inline comments\n const decisionComment = getDecisionInlineComment(options.basis);\n let yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\n );\n\n // Add comment field inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)comment: \"\"$/gm,\n '$1comment: \"\" # reason for decision'\n );\n\n const guidanceComment = getBasisGuidanceComment(options.basis);\n finalContent = guidanceComment + yamlWithComments;\n } else {\n // Final decision mode: --finalize, or no --basis (backward compat)\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildFinalizeArticle(article, options.basis)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n let yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n // Add reviews array inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)reviews: \\[\\]$/gm,\n '$1reviews: [] # add new reviews here'\n );\n\n const guidanceComment = getFinalDecisionGuidanceComment();\n finalContent = guidanceComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemaSourcePath = join(sessionDir, '.internal', 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
1
+ {"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis, type ReviewMode } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n /** When true, outputs final decision format with reviewHistory and finalDecision fields. */\n finalize?: boolean;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\nfunction getBasisGuidanceComment(basis: ReviewBasis, mode: ReviewMode = 'screening'): string {\n const schemaLine = '# yaml-language-server: $schema=./review.schema.json';\n switch (basis) {\n case 'title':\n if (mode === 'picking') {\n return [\n schemaLine,\n '# Review by title only.',\n '# Mark relevant items as \"include\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n }\n return [\n schemaLine,\n '# Screening by title only.',\n '# Mark clearly irrelevant items as \"exclude\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n case 'abstract':\n return [\n schemaLine,\n '# Screening by title and abstract.',\n '# You should be able to decide \"include\" or \"exclude\" for most items at this stage.',\n '# Mark remaining ambiguous items as \"uncertain\" with a comment explaining why.',\n '',\n ].join('\\n');\n case 'fulltext':\n return [\n schemaLine,\n '# Screening by full text. This is the final decision stage.',\n '# Decide \"include\" or \"exclude\" for each item.',\n '# Use \"uncertain\" only when absolutely unavoidable, with a comment explaining why.',\n '',\n ].join('\\n');\n }\n}\n\nfunction getDecisionInlineComment(basis: ReviewBasis, mode: ReviewMode = 'screening'): string {\n switch (basis) {\n case 'title':\n return mode === 'picking' ? '# include / uncertain' : '# exclude / uncertain';\n case 'abstract':\n case 'fulltext':\n return '# include / exclude / uncertain';\n }\n}\n\n/** Build a screening article for --basis mode: only include fields relevant to the basis */\nfunction buildScreeningArticle(article: ArticleEntry, basis: ReviewBasis): ArticleEntry {\n // Start with identifiers\n const result: ArticleEntry = { title: article.title, reviews: [] };\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Include abstract for abstract and fulltext basis\n if ((basis === 'abstract' || basis === 'fulltext') && article.abstract) {\n result.abstract = article.abstract;\n }\n\n // Include fulltext ref for fulltext basis\n if (basis === 'fulltext' && article.fulltext) {\n result.fulltext = article.fulltext;\n }\n\n // Pre-populate reviews (reviewer omitted; filled from top-level field on merge)\n result.reviews = [{ decision: 'uncertain' as const, comment: '' } as ArticleEntry['reviews'][0]];\n\n return result;\n}\n\n/** Build a finalize article with reviewHistory and finalDecision, optionally scoped by basis */\nfunction buildFinalizeArticle(article: ArticleEntry, basis?: ReviewBasis): ArticleEntry {\n const result: ArticleEntry = { title: article.title, reviews: [] };\n\n // Always include identifiers\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Always include bibliographic metadata\n if (article.authors) result.authors = article.authors;\n if (article.year) result.year = article.year;\n\n // Scope content by basis (or include all if no basis)\n if (!basis || basis === 'abstract' || basis === 'fulltext') {\n if (article.abstract) result.abstract = article.abstract;\n }\n if (!basis || basis === 'fulltext') {\n if (article.fulltext) result.fulltext = article.fulltext;\n }\n\n // Add reviewHistory (existing reviews, read-only)\n result.reviewHistory = article.reviews ?? [];\n\n // Empty reviews for new reviews\n result.reviews = [];\n\n // Null finalDecision as placeholder\n result.finalDecision = null;\n\n return result;\n}\n\nfunction getFinalDecisionGuidanceComment(): string {\n return [\n '# yaml-language-server: $schema=./review.schema.json',\n '# Final decision file: set finalDecision on each article',\n '# Valid decisions: include / exclude / null',\n '',\n ].join('\\n');\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Load mode from master file (default: screening)\n const mode: ReviewMode = reviewFile.mode ?? 'screening';\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n\n let finalContent: string;\n\n if (options.basis && !options.finalize) {\n // Screening mode: basis-scoped content with pre-populated reviews\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildScreeningArticle(article, options.basis!)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Add decision inline comments\n const decisionComment = getDecisionInlineComment(options.basis, mode);\n let yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\n );\n\n // Add comment field inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)comment: \"\"$/gm,\n '$1comment: \"\" # reason for decision'\n );\n\n const guidanceComment = getBasisGuidanceComment(options.basis, mode);\n finalContent = guidanceComment + yamlWithComments;\n } else {\n // Final decision mode: --finalize, or no --basis (backward compat)\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildFinalizeArticle(article, options.basis)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n let yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n // Add reviews array inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)reviews: \\[\\]$/gm,\n '$1reviews: [] # add new reviews here'\n );\n\n const guidanceComment = getFinalDecisionGuidanceComment();\n finalContent = guidanceComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemaSourcePath = join(sessionDir, '.internal', 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
@@ -6,10 +6,10 @@ function createEmptySkippedByStatus() {
6
6
  return {
7
7
  pending: 0,
8
8
  incomplete: 0,
9
- uncertain: 0,
9
+ "all-uncertain": 0,
10
10
  "agreed-include": 0,
11
11
  "agreed-exclude": 0,
12
- conflicting: 0,
12
+ divided: 0,
13
13
  finalized: 0
14
14
  };
15
15
  }
@@ -74,11 +74,11 @@ function formatFinalizeOutput(result, options) {
74
74
  if (result.skippedByStatus.incomplete > 0) {
75
75
  skippedParts.push(`${result.skippedByStatus.incomplete} incomplete`);
76
76
  }
77
- if (result.skippedByStatus.uncertain > 0) {
78
- skippedParts.push(`${result.skippedByStatus.uncertain} uncertain`);
77
+ if (result.skippedByStatus["all-uncertain"] > 0) {
78
+ skippedParts.push(`${result.skippedByStatus["all-uncertain"]} all-uncertain`);
79
79
  }
80
- if (result.skippedByStatus.conflicting > 0) {
81
- skippedParts.push(`${result.skippedByStatus.conflicting} conflicting`);
80
+ if (result.skippedByStatus.divided > 0) {
81
+ skippedParts.push(`${result.skippedByStatus.divided} divided`);
82
82
  }
83
83
  if (options?.decision && result.skippedByStatus["agreed-include"] > 0) {
84
84
  skippedParts.push(`${result.skippedByStatus["agreed-include"]} agreed-include (filtered)`);
@@ -1 +1 @@
1
- {"version":3,"file":"finalize.js","sources":["../../../../src/cli/commands/review/finalize.ts"],"sourcesContent":["/**\n * review finalize command - Auto-set finalDecision for articles with consensus\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ReviewStatus } from './types.js';\n\nexport interface ReviewFinalizeOptions {\n sessionId: string;\n dryRun?: boolean;\n minReviewers?: number;\n decision?: 'include' | 'exclude';\n}\n\nexport interface ReviewFinalizeResult {\n includedCount: number;\n excludedCount: number;\n skippedByStatus: Record<ReviewStatus, number>;\n}\n\nfunction createEmptySkippedByStatus(): Record<ReviewStatus, number> {\n return {\n pending: 0,\n incomplete: 0,\n uncertain: 0,\n 'agreed-include': 0,\n 'agreed-exclude': 0,\n conflicting: 0,\n finalized: 0,\n };\n}\n\n/**\n * Execute review finalize command\n */\nexport async function executeReviewFinalize(\n options: ReviewFinalizeOptions,\n sessionsDir: string\n): Promise<ReviewFinalizeResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n\n const reviewers = reviewFile.reviewers ?? [];\n const minReviewers = options.minReviewers ?? 1;\n\n const result: ReviewFinalizeResult = {\n includedCount: 0,\n excludedCount: 0,\n skippedByStatus: createEmptySkippedByStatus(),\n };\n\n for (const article of reviewFile.articles) {\n const status = classifyStatus(article, reviewers);\n\n if (status === 'agreed-include' || status === 'agreed-exclude') {\n // Check decision filter\n const consensusDecision = status === 'agreed-include' ? 'include' : 'exclude';\n if (options.decision && options.decision !== consensusDecision) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n // Check minimum reviewer count\n const reviews = article.reviews ?? [];\n const uniqueReviewers = new Set(reviews.map((r) => r.reviewer));\n if (uniqueReviewers.size < minReviewers) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n if (!options.dryRun) {\n article.finalDecision = consensusDecision;\n }\n\n if (status === 'agreed-include') {\n result.includedCount++;\n } else {\n result.excludedCount++;\n }\n } else {\n result.skippedByStatus[status]++;\n }\n }\n\n // Write back if not dry-run\n if (!options.dryRun) {\n const yamlContent = stringifyYaml(reviewFile, { lineWidth: 0 });\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n await writeFile(reviewsPath, schemaComment + yamlContent, 'utf-8');\n }\n\n return result;\n}\n\n/**\n * Format finalize result as human-readable string\n */\nexport function formatFinalizeOutput(\n result: ReviewFinalizeResult,\n options?: { dryRun?: boolean; decision?: 'include' | 'exclude' }\n): string {\n const lines: string[] = [];\n\n if (options?.dryRun) {\n lines.push('Dry run - no changes made');\n lines.push('');\n }\n\n const total = result.includedCount + result.excludedCount;\n lines.push(`Finalized ${total} articles (${result.includedCount} include, ${result.excludedCount} exclude)`);\n\n // Build skipped summary (only non-zero, non-agreed statuses)\n const skippedParts: string[] = [];\n if (result.skippedByStatus.pending > 0) {\n skippedParts.push(`${result.skippedByStatus.pending} pending`);\n }\n if (result.skippedByStatus.incomplete > 0) {\n skippedParts.push(`${result.skippedByStatus.incomplete} incomplete`);\n }\n if (result.skippedByStatus.uncertain > 0) {\n skippedParts.push(`${result.skippedByStatus.uncertain} uncertain`);\n }\n if (result.skippedByStatus.conflicting > 0) {\n skippedParts.push(`${result.skippedByStatus.conflicting} conflicting`);\n }\n\n // Show filtered-out agreed counts when --decision is active\n if (options?.decision && result.skippedByStatus['agreed-include'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-include']} agreed-include (filtered)`);\n }\n if (options?.decision && result.skippedByStatus['agreed-exclude'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-exclude']} agreed-exclude (filtered)`);\n }\n\n if (skippedParts.length > 0) {\n lines.push(`Skipped: ${skippedParts.join(', ')}`);\n }\n\n return lines.join('\\n');\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
1
+ {"version":3,"file":"finalize.js","sources":["../../../../src/cli/commands/review/finalize.ts"],"sourcesContent":["/**\n * review finalize command - Auto-set finalDecision for articles with consensus\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ReviewStatus } from './types.js';\n\nexport interface ReviewFinalizeOptions {\n sessionId: string;\n dryRun?: boolean;\n minReviewers?: number;\n decision?: 'include' | 'exclude';\n}\n\nexport interface ReviewFinalizeResult {\n includedCount: number;\n excludedCount: number;\n skippedByStatus: Record<ReviewStatus, number>;\n}\n\nfunction createEmptySkippedByStatus(): Record<ReviewStatus, number> {\n return {\n pending: 0,\n incomplete: 0,\n 'all-uncertain': 0,\n 'agreed-include': 0,\n 'agreed-exclude': 0,\n divided: 0,\n finalized: 0,\n };\n}\n\n/**\n * Execute review finalize command\n */\nexport async function executeReviewFinalize(\n options: ReviewFinalizeOptions,\n sessionsDir: string\n): Promise<ReviewFinalizeResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n\n const reviewers = reviewFile.reviewers ?? [];\n const minReviewers = options.minReviewers ?? 1;\n\n const result: ReviewFinalizeResult = {\n includedCount: 0,\n excludedCount: 0,\n skippedByStatus: createEmptySkippedByStatus(),\n };\n\n for (const article of reviewFile.articles) {\n const status = classifyStatus(article, reviewers);\n\n if (status === 'agreed-include' || status === 'agreed-exclude') {\n // Check decision filter\n const consensusDecision = status === 'agreed-include' ? 'include' : 'exclude';\n if (options.decision && options.decision !== consensusDecision) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n // Check minimum reviewer count\n const reviews = article.reviews ?? [];\n const uniqueReviewers = new Set(reviews.map((r) => r.reviewer));\n if (uniqueReviewers.size < minReviewers) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n if (!options.dryRun) {\n article.finalDecision = consensusDecision;\n }\n\n if (status === 'agreed-include') {\n result.includedCount++;\n } else {\n result.excludedCount++;\n }\n } else {\n result.skippedByStatus[status]++;\n }\n }\n\n // Write back if not dry-run\n if (!options.dryRun) {\n const yamlContent = stringifyYaml(reviewFile, { lineWidth: 0 });\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n await writeFile(reviewsPath, schemaComment + yamlContent, 'utf-8');\n }\n\n return result;\n}\n\n/**\n * Format finalize result as human-readable string\n */\nexport function formatFinalizeOutput(\n result: ReviewFinalizeResult,\n options?: { dryRun?: boolean; decision?: 'include' | 'exclude' }\n): string {\n const lines: string[] = [];\n\n if (options?.dryRun) {\n lines.push('Dry run - no changes made');\n lines.push('');\n }\n\n const total = result.includedCount + result.excludedCount;\n lines.push(`Finalized ${total} articles (${result.includedCount} include, ${result.excludedCount} exclude)`);\n\n // Build skipped summary (only non-zero, non-agreed statuses)\n const skippedParts: string[] = [];\n if (result.skippedByStatus.pending > 0) {\n skippedParts.push(`${result.skippedByStatus.pending} pending`);\n }\n if (result.skippedByStatus.incomplete > 0) {\n skippedParts.push(`${result.skippedByStatus.incomplete} incomplete`);\n }\n if (result.skippedByStatus['all-uncertain'] > 0) {\n skippedParts.push(`${result.skippedByStatus['all-uncertain']} all-uncertain`);\n }\n if (result.skippedByStatus.divided > 0) {\n skippedParts.push(`${result.skippedByStatus.divided} divided`);\n }\n\n // Show filtered-out agreed counts when --decision is active\n if (options?.decision && result.skippedByStatus['agreed-include'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-include']} agreed-include (filtered)`);\n }\n if (options?.decision && result.skippedByStatus['agreed-exclude'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-exclude']} agreed-exclude (filtered)`);\n }\n\n if (skippedParts.length > 0) {\n lines.push(`Skipped: ${skippedParts.join(', ')}`);\n }\n\n return lines.join('\\n');\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":";;;;AAsBA,SAAS,6BAA2D;AAClE,SAAO;AAAA,IACL,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,iBAAiB;AAAA,IACjB,kBAAkB;AAAA,IAClB,kBAAkB;AAAA,IAClB,SAAS;AAAA,IACT,WAAW;AAAA,EAAA;AAEf;AAKA,eAAsB,sBACpB,SACA,aAC+B;AAC/B,QAAM,aAAa,KAAK,aAAa,QAAQ,SAAS;AACtD,QAAM,cAAc,KAAK,YAAY,aAAa,cAAc;AAChE,QAAM,UAAU,MAAM,SAAS,aAAa,OAAO;AACnD,QAAM,aAAaA,MAAU,OAAO;AAEpC,QAAM,YAAY,WAAW,aAAa,CAAA;AAC1C,QAAM,eAAe,QAAQ,gBAAgB;AAE7C,QAAM,SAA+B;AAAA,IACnC,eAAe;AAAA,IACf,eAAe;AAAA,IACf,iBAAiB,2BAAA;AAAA,EAA2B;AAG9C,aAAW,WAAW,WAAW,UAAU;AACzC,UAAM,SAAS,eAAe,SAAS,SAAS;AAEhD,QAAI,WAAW,oBAAoB,WAAW,kBAAkB;AAE9D,YAAM,oBAAoB,WAAW,mBAAmB,YAAY;AACpE,UAAI,QAAQ,YAAY,QAAQ,aAAa,mBAAmB;AAC9D,eAAO,gBAAgB,MAAM;AAC7B;AAAA,MACF;AAGA,YAAM,UAAU,QAAQ,WAAW,CAAA;AACnC,YAAM,kBAAkB,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC;AAC9D,UAAI,gBAAgB,OAAO,cAAc;AACvC,eAAO,gBAAgB,MAAM;AAC7B;AAAA,MACF;AAEA,UAAI,CAAC,QAAQ,QAAQ;AACnB,gBAAQ,gBAAgB;AAAA,MAC1B;AAEA,UAAI,WAAW,kBAAkB;AAC/B,eAAO;AAAA,MACT,OAAO;AACL,eAAO;AAAA,MACT;AAAA,IACF,OAAO;AACL,aAAO,gBAAgB,MAAM;AAAA,IAC/B;AAAA,EACF;AAGA,MAAI,CAAC,QAAQ,QAAQ;AACnB,UAAM,cAAcC,UAAc,YAAY,EAAE,WAAW,GAAG;AAC9D,UAAM,gBAAgB;AAAA;AACtB,UAAM,UAAU,aAAa,gBAAgB,aAAa,OAAO;AAAA,EACnE;AAEA,SAAO;AACT;AAKO,SAAS,qBACd,QACA,SACQ;AACR,QAAM,QAAkB,CAAA;AAExB,MAAI,SAAS,QAAQ;AACnB,UAAM,KAAK,2BAA2B;AACtC,UAAM,KAAK,EAAE;AAAA,EACf;AAEA,QAAM,QAAQ,OAAO,gBAAgB,OAAO;AAC5C,QAAM,KAAK,aAAa,KAAK,cAAc,OAAO,aAAa,aAAa,OAAO,aAAa,WAAW;AAG3G,QAAM,eAAyB,CAAA;AAC/B,MAAI,OAAO,gBAAgB,UAAU,GAAG;AACtC,iBAAa,KAAK,GAAG,OAAO,gBAAgB,OAAO,UAAU;AAAA,EAC/D;AACA,MAAI,OAAO,gBAAgB,aAAa,GAAG;AACzC,iBAAa,KAAK,GAAG,OAAO,gBAAgB,UAAU,aAAa;AAAA,EACrE;AACA,MAAI,OAAO,gBAAgB,eAAe,IAAI,GAAG;AAC/C,iBAAa,KAAK,GAAG,OAAO,gBAAgB,eAAe,CAAC,gBAAgB;AAAA,EAC9E;AACA,MAAI,OAAO,gBAAgB,UAAU,GAAG;AACtC,iBAAa,KAAK,GAAG,OAAO,gBAAgB,OAAO,UAAU;AAAA,EAC/D;AAGA,MAAI,SAAS,YAAY,OAAO,gBAAgB,gBAAgB,IAAI,GAAG;AACrE,iBAAa,KAAK,GAAG,OAAO,gBAAgB,gBAAgB,CAAC,4BAA4B;AAAA,EAC3F;AACA,MAAI,SAAS,YAAY,OAAO,gBAAgB,gBAAgB,IAAI,GAAG;AACrE,iBAAa,KAAK,GAAG,OAAO,gBAAgB,gBAAgB,CAAC,4BAA4B;AAAA,EAC3F;AAEA,MAAI,aAAa,SAAS,GAAG;AAC3B,UAAM,KAAK,YAAY,aAAa,KAAK,IAAI,CAAC,EAAE;AAAA,EAClD;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;"}
@@ -1,8 +1,7 @@
1
- /**
2
- * review init command - Generate reviews.yaml from session results
3
- */
1
+ import { ReviewMode } from './types.js';
4
2
  export interface ReviewInitOptions {
5
3
  sessionId: string;
4
+ mode?: ReviewMode;
6
5
  force?: boolean;
7
6
  }
8
7
  export interface ReviewInitResult {
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/init.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAyDD;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,gBAAgB,CAAC,CA6E3B"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/init.ts"],"names":[],"mappings":"AAAA;;GAEG;AAUH,OAAO,KAAK,EAA4B,UAAU,EAAgB,MAAM,YAAY,CAAC;AAErF,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAyDD;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,gBAAgB,CAAC,CA8E3B"}
@@ -67,6 +67,7 @@ async function executeReviewInit(options, sessionsDir) {
67
67
  const articleEntries = dedupedArticles.map(articleToEntry);
68
68
  const reviewFile = {
69
69
  sessionId: options.sessionId,
70
+ ...options.mode && { mode: options.mode },
70
71
  articles: articleEntries
71
72
  };
72
73
  const yamlContent = stringify(reviewFile, {