salesprompter-cli 0.1.83 → 0.1.84

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.
package/README.md CHANGED
@@ -44,6 +44,24 @@ For headless or automation use, generate a CLI token in the app and run `salespr
44
44
 
45
45
  `leads:at-companies` researches Director, Head-of, VP and C-level contacts across functions. It exports a shortlist for review without starting email enrichment or outreach.
46
46
 
47
+ Start with company names; no LinkedIn IDs are needed in your brief:
48
+
49
+ ```json
50
+ { "companies": [{ "name": "Your target company", "maxContacts": 20 }] }
51
+ ```
52
+
53
+ ```bash
54
+ salesprompter auth:login
55
+ salesprompter browser:connect
56
+ salesprompter leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
57
+ ```
58
+
59
+ After a successful connection, Chrome is remembered for company research. If names are ambiguous, an interactive terminal shows verified candidate locations, websites and evidence links. Choose a number, then confirm; nothing is preselected. The CLI saves the decision and resumes without editing your brief. Use `--review` to review a saved ambiguity later. Enter 0 or press Enter to leave it unresolved.
60
+
61
+ JSON, quiet and non-interactive runs never prompt. Their `outcome` is `complete`, `incomplete` or `needs_review`; `nextCommand` gives the terminal command for pending collection or identity review. `collectionComplete` refers only to collection, so it can be true while role review remains. Add `--require-complete` for exit code 2 whenever collection or review is unfinished. Without it, exit 0 means the command ran, not that all research is complete. `--no-interactive` disables prompts in a terminal too.
62
+
63
+ Guided review fetches native details for at most ten exact-name candidates. Non-exact, composite or larger identity choices still need independently checked `--review-file` decisions. Missing/mismatched details cannot be approved. Explicit connection flags override the remembered choice; failed Chrome authentication never falls back silently to another identity.
64
+
47
65
  The checkpoint, shortlist and review exports refresh after every collected page, so long runs expose saved progress immediately. Company labels with punctuation, such as `(TKMS)`, are escaped safely in search queries.
48
66
 
49
67
  Use `--report-only` to refresh saved reports without contacting LinkedIn. The workspace-bound checkpoint is still checked. `progress` separates unique shortlisted people, review rows, review people, review-only people, pending searches, partial/unknown coverage and unresolved companies. `collectionComplete` is false while any coverage gap remains; `nextActions` explains what is left. Unrestricted geography is labeled explicitly.
@@ -93,23 +111,23 @@ The CLI validates and persists the complete review file as `review-decisions.jso
93
111
 
94
112
  Partial continuation checkpoints the raw next offset and deduplicates people. Old checkpoints without an offset replay the first page once before advancing. `quota` stops at the shortlist ceiling; `exhaustive` means attempting the accessible 2,500-result window, **not** a promise of exhaustive coverage. Count changes, empty/repeated pages and larger result sets remain explicitly incomplete. Stopped searches require investigation; the CLI does not automatically clear `stoppedReason`. `--all` controls the number of jobs, not these safety limits. Make a private copy of the output directory before comparing old and new role-policy results.
95
113
 
96
- For standalone browser research, sign in once to the CLI-owned Chrome profile, then select Chrome:
114
+ For standalone browser research, sign in once to the CLI-owned Chrome profile:
97
115
 
98
116
  ```bash
99
117
  salesprompter browser:connect
100
118
  salesprompter leads:at-companies --brief companies.json --out-dir ./research \
101
- --resolve-companies --all --browser chrome
119
+ --resolve-companies --all
102
120
  ```
103
121
 
104
122
  `browser:connect` opens a separate Chrome window and waits up to ten minutes for your manual Sales Navigator sign-in (change with `--wait <seconds>`). It confirms a successful native authenticated request, then closes its own browser. Google Chrome must be installed. The private profile lives at `~/.config/salesprompter/chrome-research` (or under `SALESPROMPTER_CONFIG_DIR`). Your everyday Chrome profile stays untouched. The control connection uses a private OS pipe, not a debugging port. No cookies or browser storage are read/exported. The CLI keeps only allowlisted native request headers in memory.
105
123
 
106
124
  Research runs reuse that profile in background Chrome, execute the GET requests themselves, and close their own browser when finished. No Codex task, relay worker, extension, or manual request polling is needed. A first sign-in, expired login, or security challenge still requires you: rerun `browser:connect`, then repeat the research command with the same output directory. Rate limits stop immediately without switching accounts. `--browser chrome`, `--browser-relay-port`, and `--wait-for-session` are mutually exclusive. Chrome connects lazily, so completed checkpoints and report-only runs never launch it.
107
125
 
108
- The default `--browser session` keeps the extension-credential route. Credentials load only for pending requests; on an auth failure it tries changed credentials for the same identity once. Add `--wait-for-session 300` for a bounded refresh wait. The optional Codex `--browser-relay-port` route still needs its external worker. Salesprompter workspace authentication applies in every mode.
126
+ `browser:connect` remembers Chrome only after confirming a successful authenticated request. `browser:connect --check` checks an existing sign-in in background and also remembers it; it does not open a sign-in window. The preference is private and scoped to the CLI configuration directory. Explicit `--browser session` selects extension credentials; without a remembered choice, session remains the fallback. Explicit relay/wait flags take precedence over the preference. Credentials load only for pending requests; on an auth failure the session route tries changed credentials for the same identity once. Add `--wait-for-session 300` for a bounded refresh wait. The optional Codex `--browser-relay-port` route still needs its external worker. Salesprompter workspace authentication applies in every mode.
109
127
 
110
128
  Ctrl+C, SIGTERM and terminal hangup stop Chrome research gracefully: completed searches stay saved, the owned browser closes, and the research lock is released before exit. Repeat the same command to resume only pending work. A forced kill or power loss cannot run cleanup; if a stale lock remains, confirm that its process stopped before removing only `.research-lock` as the CLI error explains.
111
129
 
112
- Create a JSON brief with verified numeric LinkedIn company IDs. Use each subsidiary's own ID; names alone are not an employer match. Missing IDs stay in the coverage report as unresolved.
130
+ Create a JSON brief with company names or verified numeric LinkedIn company IDs. Use `--resolve-companies` for names. Each subsidiary must resolve to its own ID; unresolved identities stay in the coverage report and are never guessed.
113
131
 
114
132
  For a name-only list, the executable end-to-end research mode is:
115
133
 
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@ import { z } from "zod";
17
17
  import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
18
18
  import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
19
19
  import { CompanyReviewSchema, resolveCompanyIdentity } from "./company-recovery.js";
20
+ import { chooseCompanyIdentity, companyResearchNextCommand, createCompanyReviewPrompt } from "./company-identity-review.js";
21
+ import { rememberResearchBrowser, selectResearchBrowser } from "./research-browser-preference.js";
20
22
  import { createLazySessionRecovery, waitForFreshSession, isFreshSessionForIdentity } from "./session-recovery.js";
21
23
  import { createChromeResearchBrowser, ChromeResearchInterruptedError } from "./chrome-browser.js";
22
24
  import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
@@ -15142,10 +15144,13 @@ program
15142
15144
  .command("browser:connect")
15143
15145
  .description("Sign in once to the CLI-owned research Chrome; no external browser worker required.")
15144
15146
  .option("--wait <seconds>", "Time allowed for manual LinkedIn sign-in", "600")
15147
+ .option("--check", "Check the saved sign-in in background without opening a sign-in window", false)
15145
15148
  .action(async (options) => {
15146
- const browser = createChromeResearchBrowser({ visible: true, loginWaitMs: z.coerce.number().int().min(1).max(3600).parse(options.wait) * 1000, onProgress: message => process.stderr.write(`${message}\n`) });
15149
+ const browser = createChromeResearchBrowser({ visible: !options.check, loginWaitMs: options.check ? 5000 : z.coerce.number().int().min(1).max(3600).parse(options.wait) * 1000, onProgress: message => process.stderr.write(`${message}\n`) });
15147
15150
  try {
15148
- printOutput({ status: "ok", ...await browser.connect() });
15151
+ const connection = await browser.connect();
15152
+ await rememberResearchBrowser(getSalesprompterConfigDir(), "chrome");
15153
+ printOutput({ status: "ok", ...connection, defaultResearchBrowser: "chrome" });
15149
15154
  }
15150
15155
  finally {
15151
15156
  await browser.close();
@@ -15154,7 +15159,7 @@ program
15154
15159
  program
15155
15160
  .command("leads:at-companies")
15156
15161
  .description("Find senior contacts at named companies, balanced across functions; export a review shortlist.")
15157
- .requiredOption("--brief <path>", "JSON company list with verified company IDs and optional role criteria")
15162
+ .requiredOption("--brief <path>", "JSON company names or verified IDs, with optional role criteria")
15158
15163
  .option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
15159
15164
  .option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
15160
15165
  .option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
@@ -15162,15 +15167,24 @@ program
15162
15167
  .option("--resolve-companies", "Resolve unique exact company names and collect their contacts in the same run", false)
15163
15168
  .option("--retry-unresolved", "Retry cached identity misses without discarding completed searches (requires --resolve-companies)", false)
15164
15169
  .option("--review-file <path>", "Apply fingerprint-bound, evidence-backed identity/alias decisions without changing the brief")
15170
+ .option("--review", "Choose unresolved company identities in this terminal, then resume collection", false)
15171
+ .option("--no-interactive", "Never prompt for company review; return needs_review for unresolved choices")
15172
+ .option("--require-complete", "Exit 2 unless collection and review are complete; still write reports", false)
15165
15173
  .option("--verify-employers", "Verify canonical employer names against LinkedIn company details and save safe aliases", false)
15166
15174
  .option("--continue-partial <mode>", "Resume saved partial pages: quota or exhaustive (2500-result window)")
15167
15175
  .option("--company <name>", "Limit recovery to an exact brief company name; repeat for multiple companies", (value, previous) => [...previous, value], [])
15168
15176
  .option("--wait-for-session <seconds>", "Wait for a refreshed extension session after an auth failure; never retry rate limits", "0")
15169
15177
  .option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
15170
- .option("--browser <mode>", "Use chrome for CLI-owned browser research, or session for extension credentials", "session")
15178
+ .option("--browser <mode>", "Override saved connection: chrome or session; browser:connect remembers chrome")
15171
15179
  .option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
15172
15180
  .action(async (options) => {
15173
- const browserMode = z.enum(["chrome", "session"]).parse(options.browser);
15181
+ const browserMode = await selectResearchBrowser(options, getSalesprompterConfigDir());
15182
+ const canReview = Boolean(process.stdin.isTTY && process.stderr.isTTY && !runtimeOutputOptions.json && !runtimeOutputOptions.quiet && options.interactive);
15183
+ if (options.review && !canReview)
15184
+ throw new Error("--review requires an interactive terminal without --json, --quiet or --no-interactive. Run the displayed nextCommand in a terminal, or use --review-file for an evidence-backed scripted decision.");
15185
+ if (options.review && (options.reportOnly || options.dryRun))
15186
+ throw new Error("--review cannot be combined with --report-only or --dry-run; candidate verification requires live company details.");
15187
+ const interactiveReview = canReview && !options.reportOnly && !options.dryRun && (options.review || options.resolveCompanies);
15174
15188
  if (options.retryUnresolved && !options.resolveCompanies)
15175
15189
  throw new Error("--retry-unresolved requires --resolve-companies.");
15176
15190
  const waitMs = z.coerce.number().int().min(0).max(3600).parse(options.waitForSession) * 1000;
@@ -15188,16 +15202,30 @@ program
15188
15202
  const maxSearches = options.all ? Number.MAX_SAFE_INTEGER : z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
15189
15203
  if (options.dryRun) {
15190
15204
  const inScope = (name) => !options.company.length || options.company.includes(name);
15191
- printOutput({ status: "ok", dryRun: true, searches: jobs.filter(job => inScope(job.company.name)), unresolvedCompanies: brief.companies.filter(c => !c.companyId && inScope(c.name)), maxSearches, recovery: { companies: options.company, verifyEmployers: options.verifyEmployers, continuePartial: continuePartial ?? null, reviewFile: options.reviewFile ? path.resolve(options.reviewFile) : null, savedCheckpointNotRead: true }, outreachStarted: false, emailEnrichmentStarted: false });
15205
+ printOutput({ status: "ok", outcome: "preview", browser: browserMode, dryRun: true, searches: jobs.filter(job => inScope(job.company.name)), unresolvedCompanies: brief.companies.filter(c => !c.companyId && inScope(c.name)), maxSearches, recovery: { companies: options.company, verifyEmployers: options.verifyEmployers, continuePartial: continuePartial ?? null, reviewFile: options.reviewFile ? path.resolve(options.reviewFile) : null, savedCheckpointNotRead: true }, outreachStarted: false, emailEnrichmentStarted: false });
15192
15206
  return;
15193
15207
  }
15194
15208
  const session = await requireAuthSession();
15195
15209
  const orgId = session.user.orgId;
15196
15210
  if (!orgId)
15197
15211
  throw new Error("Choose a Salesprompter workspace before company research.");
15212
+ const finish = (report, reportOnly = false) => {
15213
+ const nextCommand = companyResearchNextCommand({ brief: path.resolve(options.brief), outDir: report.output, browser: browserMode, unresolved: report.progress.unresolvedCompanies, pending: report.progress.remainingSearches, companies: options.company });
15214
+ writeProgress(`\n${report.outcome.replace(/_/g, " ").toUpperCase()}: ${report.selected.length} contacts; ${report.progress.completedSearches}/${report.progress.totalSearches} searches; ${report.progress.reviewOnlyPeople} additional people to review.`);
15215
+ if (nextCommand)
15216
+ writeProgress(`Next: ${nextCommand}`);
15217
+ if (report.review.length)
15218
+ writeProgress(`Role/employer review: ${path.join(report.output, "review.csv")}`);
15219
+ if (process.stdout.isTTY && !runtimeOutputOptions.json)
15220
+ writeProgress(`Contacts: ${path.join(report.output, "contacts.csv")}\nReport: ${path.join(report.output, "coverage.json")}`);
15221
+ else
15222
+ printOutput({ status: report.status, outcome: report.outcome, browser: browserMode, reportOnly, selected: report.selected.length, reviewRequired: report.review.length, progress: report.progress, nextActions: report.nextActions, nextCommand, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
15223
+ if (options.requireComplete && report.outcome !== "complete")
15224
+ process.exitCode = 2;
15225
+ };
15198
15226
  if (options.reportOnly) {
15199
15227
  const report = await runCompanyResearch({ brief, review, onlyCompanies: options.company, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches: 0, search: async () => { throw new Error("Report-only cannot collect leads."); } });
15200
- printOutput({ status: "ok", reportOnly: true, progress: report.progress, nextActions: report.nextActions, output: report.output, outreachStarted: false, emailEnrichmentStarted: false });
15228
+ finish(report, true);
15201
15229
  return;
15202
15230
  }
15203
15231
  const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
@@ -15252,12 +15280,21 @@ program
15252
15280
  const record = cliImportCompanyRecord(body?.[id]);
15253
15281
  const explicitId = record?.entityUrn ? extractLinkedInSalesCompanyIdFromUrn(record.entityUrn) : null;
15254
15282
  const evidence = response.status === 200 && typeof record?.name === "string" && (!explicitId || explicitId === id)
15255
- ? { companyId: id, name: record.name, website: typeof record.website === "string" ? record.website : undefined, evidenceUrl: `https://www.linkedin.com/sales/company/${id}` } : null;
15283
+ ? { companyId: id, name: record.name, website: typeof record.website === "string" ? record.website : undefined, location: formatCliImportCompanyLocation(record.headquarters) ?? formatCliImportCompanyLocation(record.location) ?? undefined, evidenceUrl: `https://www.linkedin.com/sales/company/${id}` } : null;
15256
15284
  detailsCache.set(id, evidence);
15257
15285
  return evidence;
15258
15286
  };
15259
15287
  try {
15260
15288
  const report = await runCompanyResearch({ brief, review, onlyCompanies: options.company, continuePartial, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches, retryUnresolved: options.retryUnresolved,
15289
+ reviewIdentity: !interactiveReview ? undefined : async (company, diagnostic) => {
15290
+ const prompt = createCompanyReviewPrompt();
15291
+ try {
15292
+ return await chooseCompanyIdentity({ targetName: company.name, diagnostic, details, io: prompt.io });
15293
+ }
15294
+ finally {
15295
+ prompt.close();
15296
+ }
15297
+ },
15261
15298
  verifyEmployer: options.verifyEmployers ? details : undefined,
15262
15299
  diagnoseCompany: !options.resolveCompanies ? undefined : company => resolveCompanyIdentity(company.name, async (query, start) => {
15263
15300
  await paced();
@@ -15295,7 +15332,7 @@ program
15295
15332
  });
15296
15333
  },
15297
15334
  });
15298
- printOutput({ status: report.status, selected: report.selected.length, reviewRequired: report.review.length, progress: report.progress, nextActions: report.nextActions, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
15335
+ finish(report);
15299
15336
  }
15300
15337
  finally {
15301
15338
  await relay?.close();
@@ -0,0 +1,78 @@
1
+ import { companyIdentityKey } from "./company-recovery.js";
2
+ import { createInterface } from "node:readline/promises";
3
+ /** Cancelling a terminal question must reject it so research can release its lock. */
4
+ export function createCompanyReviewPrompt(input = process.stdin, output = process.stderr) {
5
+ const rl = createInterface({ input, output, terminal: true });
6
+ const cancellation = new AbortController();
7
+ const cancel = () => cancellation.abort();
8
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
9
+ rl.on("SIGINT", cancel);
10
+ rl.once("close", cancel);
11
+ for (const signal of signals)
12
+ process.on(signal, cancel);
13
+ return {
14
+ io: {
15
+ write: (text) => { output.write(text); },
16
+ ask: async (question) => {
17
+ try {
18
+ return await rl.question(question, { signal: cancellation.signal });
19
+ }
20
+ catch (error) {
21
+ if (cancellation.signal.aborted)
22
+ throw new Error("Company review cancelled. Saved work is preserved; rerun with the same --out-dir to resume.");
23
+ throw error;
24
+ }
25
+ },
26
+ },
27
+ close: () => {
28
+ for (const signal of signals)
29
+ process.off(signal, cancel);
30
+ rl.close();
31
+ },
32
+ };
33
+ }
34
+ export const terminalText = (text) => text.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 300);
35
+ export async function chooseCompanyIdentity(input) {
36
+ const { targetName, diagnostic, details, io } = input;
37
+ const candidates = [...new Map(diagnostic.candidates.filter(c => /^[1-9]\d*$/.test(c.companyId) && companyIdentityKey(c.name) === companyIdentityKey(targetName)).map(c => [c.companyId, c])).values()];
38
+ io.write(`\nCompany review: ${terminalText(targetName)}\n`);
39
+ if (!candidates.length || candidates.length > 10) {
40
+ io.write("No bounded exact-name choice is available. Inspect recovery.json and use an evidence-backed --review-file; this company remains unresolved.\n");
41
+ return null;
42
+ }
43
+ const verified = [];
44
+ for (const candidate of candidates) {
45
+ const evidence = await details(candidate.companyId);
46
+ if (!evidence || evidence.companyId !== candidate.companyId || evidence.evidenceUrl !== `https://www.linkedin.com/sales/company/${candidate.companyId}` || companyIdentityKey(evidence.name) !== companyIdentityKey(candidate.name))
47
+ continue;
48
+ verified.push(evidence);
49
+ io.write(`\n${verified.length}. ${terminalText(evidence.name)}\n Location: ${terminalText(evidence.location ?? "not provided")}\n Website: ${terminalText(evidence.website ?? "not provided")}\n Evidence: ${terminalText(evidence.evidenceUrl)}\n`);
50
+ }
51
+ if (!verified.length) {
52
+ io.write("No candidate could be verified. Nothing approved.\n");
53
+ return null;
54
+ }
55
+ io.write("\nMatch the location and website to your intended company. No choice is preselected.\n");
56
+ while (true) {
57
+ const answer = (await io.ask("Company number [0 = skip]: ")).trim();
58
+ if (!answer || answer === "0")
59
+ return null;
60
+ if (!/^[1-9]\d*$/.test(answer) || Number(answer) > verified.length) {
61
+ io.write("Enter a listed number, or 0 to skip.\n");
62
+ continue;
63
+ }
64
+ const chosen = verified[Number(answer) - 1];
65
+ const confirm = await io.ask(`Use ${terminalText(chosen.name)} (${terminalText(chosen.location ?? chosen.companyId)}) for ${terminalText(targetName)}? [y/N]: `);
66
+ return /^(y|yes)$/i.test(confirm.trim()) ? chosen : null;
67
+ }
68
+ }
69
+ export function companyResearchNextCommand(input) {
70
+ const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
71
+ const scope = (input.companies ?? []).map(name => ` --company ${quote(name)}`).join("");
72
+ const base = `salesprompter leads:at-companies --brief ${quote(input.brief)} --out-dir ${quote(input.outDir)} --browser ${input.browser}${scope}`;
73
+ if (input.unresolved)
74
+ return `${base} --resolve-companies --review --all`;
75
+ if (input.pending)
76
+ return `${base} --all`;
77
+ return null;
78
+ }
@@ -182,11 +182,12 @@ export function shortlistCompanyLeads(brief, results) {
182
182
  };
183
183
  const nextActions = [
184
184
  ...(progress.remainingSearches ? ["Resume the unchanged brief and output directory to collect pending searches."] : []),
185
- ...(unresolved.length ? ["Inspect recovery.json; retry selected identities with --resolve-companies --retry-unresolved --company, or apply evidence-backed --review-file decisions."] : []),
185
+ ...(unresolved.length ? ["Use --review in a terminal to choose a verified exact-name identity. Inspect recovery.json for non-exact decisions or an intentional --retry-unresolved --resolve-companies retry."] : []),
186
186
  ...(incomplete.length || unknown.length ? ["Use --continue-partial quota or exhaustive to resume eligible pages; inspect stoppedReason before claiming exhaustive coverage."] : []),
187
187
  ...(review.length ? ["Use --verify-employers for canonical names, or evidence-backed --review-file aliases. Ambiguous roles remain review-only."] : []),
188
188
  ];
189
- return { selected, review, rejected, coverage, progress, nextActions, rolePolicyVersion: 2, outreachStarted: false, emailEnrichmentStarted: false };
189
+ const outcome = unresolved.length ? "needs_review" : !progress.collectionComplete ? "incomplete" : review.length ? "needs_review" : "complete";
190
+ return { outcome, selected, review, rejected, coverage, progress, nextActions, rolePolicyVersion: 2, outreachStarted: false, emailEnrichmentStarted: false };
190
191
  }
191
192
  export function companyLeadCsv(rows) {
192
193
  const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason", "reasons", "employerAliasEvidence"];
@@ -386,6 +387,31 @@ async function runCompanyResearchLocked(input) {
386
387
  await collect();
387
388
  }
388
389
  }
390
+ if (input.reviewIdentity)
391
+ for (const [index, company] of brief.companies.entries()) {
392
+ if (!allowed(company.name) || company.companyId || state.resolutions?.[String(index)])
393
+ continue;
394
+ const diagnostic = state.diagnostics?.[String(index)];
395
+ if (!diagnostic)
396
+ continue;
397
+ const choice = await input.reviewIdentity(company, diagnostic);
398
+ if (!choice)
399
+ continue;
400
+ if (!diagnostic.candidates.some(c => c.companyId === choice.companyId && companyIdentityKey(c.name) === companyIdentityKey(choice.name)))
401
+ throw new Error("Reviewed identity must match a verified candidate from this research.");
402
+ if (effectiveBrief().companies.some(c => c.companyId === choice.companyId))
403
+ throw new Error("Reviewed identity is already assigned to another target.");
404
+ const identity = { targetName: company.name, companyId: choice.companyId, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
405
+ review = CompanyReviewSchema.parse({ fingerprint, employerAliases: review?.employerAliases ?? [], identities: [...(review?.identities ?? []), identity] });
406
+ // The durable review overlay is written first. A crash before the checkpoint
407
+ // write is recovered by the normal review-file replay on the next invocation.
408
+ await save(path.join(outDir, "review-decisions.json"), JSON.stringify(review, null, 2));
409
+ state.resolutions ??= {};
410
+ state.resolutions[String(index)] = { companyId: choice.companyId, companyName: company.name, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
411
+ await save(checkpoint, JSON.stringify(state));
412
+ await publish();
413
+ await collect();
414
+ }
389
415
  }
390
416
  catch (e) {
391
417
  await publish();
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ const Preference = z.object({ browser: z.enum(["chrome", "session"]) }).strict();
6
+ export async function readResearchBrowserPreference(configDir) {
7
+ const file = path.join(configDir, "research-browser.json");
8
+ try {
9
+ return Preference.parse(JSON.parse(await readFile(file, "utf8"))).browser;
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT")
13
+ return "session";
14
+ throw new Error(`Cannot read research browser preference at ${file}. Run browser:connect again, or choose --browser chrome or --browser session explicitly.`);
15
+ }
16
+ }
17
+ export async function rememberResearchBrowser(configDir, browser) {
18
+ const value = Preference.parse({ browser });
19
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
20
+ const file = path.join(configDir, "research-browser.json");
21
+ const temporary = `${file}.${randomUUID()}.tmp`;
22
+ try {
23
+ await writeFile(temporary, JSON.stringify(value) + "\n", { mode: 0o600, flag: "wx" });
24
+ await rename(temporary, file);
25
+ }
26
+ finally {
27
+ await rm(temporary, { force: true });
28
+ }
29
+ }
30
+ export async function selectResearchBrowser(options, configDir) {
31
+ if (options.browser !== undefined)
32
+ return Preference.shape.browser.parse(options.browser);
33
+ // An explicit connection route takes precedence over the saved preference.
34
+ if (options.browserRelayPort !== undefined || Number(options.waitForSession ?? 0) > 0)
35
+ return "session";
36
+ return readResearchBrowserPreference(configDir);
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",