@agent-pattern-labs/leads-rig 0.1.3

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 (87) hide show
  1. package/.claude/agents/general-free.md +39 -0
  2. package/.claude/agents/general-paid.md +20 -0
  3. package/.claude/agents/glm-minimal.md +9 -0
  4. package/.claude/iso-route.resolved.json +21 -0
  5. package/.claude/settings.json +3 -0
  6. package/.codex/config.toml +24 -0
  7. package/.cursor/iso-route.md +17 -0
  8. package/.cursor/mcp.json +19 -0
  9. package/.cursor/rules/agent-general-free.mdc +38 -0
  10. package/.cursor/rules/agent-general-paid.mdc +19 -0
  11. package/.cursor/rules/agent-glm-minimal.mdc +8 -0
  12. package/.cursor/rules/main.mdc +61 -0
  13. package/.mcp.json +19 -0
  14. package/.opencode/agents/general-free.md +48 -0
  15. package/.opencode/agents/general-paid.md +29 -0
  16. package/.opencode/agents/glm-minimal.md +19 -0
  17. package/.opencode/instructions.md +3 -0
  18. package/.opencode/skills/lead-harness.md +56 -0
  19. package/.opencode/skills/public-leads.md +56 -0
  20. package/.pi/prompts/lead-harness.md +54 -0
  21. package/.pi/prompts/public-leads.md +54 -0
  22. package/.pi/skills/general-free/SKILL.md +38 -0
  23. package/.pi/skills/general-paid/SKILL.md +19 -0
  24. package/.pi/skills/glm-minimal/SKILL.md +8 -0
  25. package/AGENTS.md +56 -0
  26. package/CLAUDE.md +56 -0
  27. package/LICENSE +21 -0
  28. package/README.md +61 -0
  29. package/batch/README.md +37 -0
  30. package/batch/batch-prompt.md +19 -0
  31. package/batch/batch-runner.sh +18 -0
  32. package/bin/create-leads-harness.mjs +178 -0
  33. package/bin/lead-harness.mjs +201 -0
  34. package/bin/sync.mjs +150 -0
  35. package/config/profile.example.yml +34 -0
  36. package/docs/ARCHITECTURE.md +85 -0
  37. package/docs/CONSTRUCTION.md +40 -0
  38. package/docs/README.md +5 -0
  39. package/docs/SETUP.md +89 -0
  40. package/examples/README.md +9 -0
  41. package/examples/sample-leads.json +57 -0
  42. package/iso/agents/general-free.md +50 -0
  43. package/iso/agents/general-paid.md +31 -0
  44. package/iso/agents/glm-minimal.md +21 -0
  45. package/iso/commands/lead-harness.md +59 -0
  46. package/iso/commands/public-leads.md +59 -0
  47. package/iso/config.json +11 -0
  48. package/iso/instructions.md +56 -0
  49. package/iso/instructions.opencode.md +3 -0
  50. package/iso/mcp.json +16 -0
  51. package/lib/leadharness-crawler.mjs +911 -0
  52. package/lib/leadharness-ingest.mjs +157 -0
  53. package/lib/leadharness-leads.mjs +574 -0
  54. package/models.yaml +32 -0
  55. package/modes/_shared.md +50 -0
  56. package/modes/batch.md +34 -0
  57. package/modes/crawl.md +25 -0
  58. package/modes/ingest.md +33 -0
  59. package/modes/pipeline.md +27 -0
  60. package/modes/reference-local-helpers.md +13 -0
  61. package/modes/review.md +15 -0
  62. package/modes/setup.md +25 -0
  63. package/opencode.json +43 -0
  64. package/package.json +186 -0
  65. package/scripts/batch-orchestrator.mjs +558 -0
  66. package/scripts/crawl.mjs +129 -0
  67. package/scripts/ingest.mjs +48 -0
  68. package/scripts/manifest.mjs +71 -0
  69. package/scripts/pipeline.mjs +118 -0
  70. package/scripts/validate-leads.mjs +69 -0
  71. package/templates/canon.json +26 -0
  72. package/templates/capabilities.json +23 -0
  73. package/templates/context.json +20 -0
  74. package/templates/contracts.json +72 -0
  75. package/templates/facts.json +13 -0
  76. package/templates/index.json +13 -0
  77. package/templates/lead-schema.json +143 -0
  78. package/templates/lineage.json +10 -0
  79. package/templates/migrations.json +4 -0
  80. package/templates/postflight.json +11 -0
  81. package/templates/preflight.json +19 -0
  82. package/templates/prioritize.json +10 -0
  83. package/templates/redact.json +15 -0
  84. package/templates/score.json +18 -0
  85. package/templates/states.yml +25 -0
  86. package/templates/timeline.json +10 -0
  87. package/verify-pipeline.mjs +123 -0
@@ -0,0 +1,558 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from 'child_process';
4
+ import { createHash } from 'crypto';
5
+ import { existsSync } from 'fs';
6
+ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
7
+ import { dirname, join, resolve } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { runWorkflow } from '@razroo/iso-orchestrator';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const PKG_ROOT = resolve(__dirname, '..');
13
+ const PROJECT_DIR = process.env.PUBLIC_LEADS_PROJECT || process.env.LEAD_HARNESS_PROJECT || process.cwd();
14
+
15
+ const BATCH_DIR = join(PROJECT_DIR, 'batch');
16
+ const INPUT_FILE = join(BATCH_DIR, 'batch-input.tsv');
17
+ const STATE_FILE = join(BATCH_DIR, 'batch-state.tsv');
18
+ const PROMPT_FILE = join(BATCH_DIR, 'batch-prompt.md');
19
+ const LOGS_DIR = join(BATCH_DIR, 'logs');
20
+ const WORKFLOW_DIR = join(PROJECT_DIR, process.env.PUBLIC_LEADS_WORKFLOW_DIR || process.env.LEAD_HARNESS_WORKFLOW_DIR || '.public-leads-runs');
21
+ const LOCK_FILE = join(BATCH_DIR, 'batch-runner.pid');
22
+ const STATE_HEADER = 'id\tdomain\tstatus\tstarted_at\tcompleted_at\tartifact\tlead_count\terror\tretries';
23
+ const MAX_PARALLEL_WORKERS = 2;
24
+
25
+ function usage() {
26
+ console.log(`public-leads batch runner - process company domains with AI CLI workers
27
+
28
+ Usage:
29
+ batch/batch-runner.sh [OPTIONS]
30
+
31
+ Options:
32
+ --runner NAME Worker CLI: opencode or codex (default: opencode)
33
+ --parallel N Number of parallel workers (default: 1, max: 2)
34
+ --allow-unsafe-workers
35
+ Enable worker CLI permission-bypass flags (explicit opt-in)
36
+ --dry-run Show pending domains without executing workers
37
+ --retry-failed Only retry rows marked failed
38
+ --start-from N Start from numeric id N
39
+ --max-retries N Max failed retries per domain (default: 2)
40
+ --workflow-id ID Durable workflow id (default: public-leads-batch)
41
+ -h, --help Show help
42
+
43
+ Input:
44
+ batch/batch-input.tsv with columns: id, domain, company, notes
45
+ `);
46
+ }
47
+
48
+ try {
49
+ const options = parseArgs(process.argv.slice(2));
50
+ if (options.help) {
51
+ usage();
52
+ process.exit(0);
53
+ }
54
+ await main(options);
55
+ } catch (error) {
56
+ console.error(error instanceof Error ? error.message : String(error));
57
+ process.exit(1);
58
+ }
59
+
60
+ async function main(opts) {
61
+ await checkPrerequisites(opts);
62
+ const releaseLock = await acquirePidLock(opts);
63
+ try {
64
+ await initState();
65
+ const domains = await readInputDomains();
66
+ const rows = await readState();
67
+ const pending = selectPending(domains, rows, opts);
68
+
69
+ if (opts.dryRun) {
70
+ console.log(`dry run: ${pending.length} pending domain(s)`);
71
+ for (const item of pending) {
72
+ console.log(`${item.id}\t${item.domain}\t${item.company || '-'}\t${item.notes || '-'}`);
73
+ }
74
+ return;
75
+ }
76
+
77
+ const result = await runWorkflow(
78
+ {
79
+ workflowId: opts.workflowId,
80
+ dir: WORKFLOW_DIR,
81
+ initialState: { startedAt: nowIso(), completed: 0, failed: 0 },
82
+ },
83
+ async (workflow) => {
84
+ const summary = await workflow.forEach(
85
+ pending,
86
+ (item) => workflow.step(
87
+ `crawl:${item.id}:${hash(item.domain)}`,
88
+ () => processDomain(workflow, item, opts),
89
+ { idempotencyKey: `${item.id}:${item.domain}:${retriesFor(rows, item.id)}` },
90
+ ),
91
+ {
92
+ maxParallel: opts.parallel,
93
+ mutexKey: (item) => `domain:${item.domain}`,
94
+ stopOnError: false,
95
+ },
96
+ );
97
+ await workflow.updateState((state) => ({
98
+ ...state,
99
+ completedAt: nowIso(),
100
+ completed: summary.results.filter((item) => item.status === 'fulfilled' && item.value?.status === 'completed').length,
101
+ failed: summary.results.filter((item) => item.status === 'rejected' || item.value?.status === 'failed').length,
102
+ }));
103
+ return summary;
104
+ },
105
+ );
106
+
107
+ const fulfilled = result.value.results.filter((item) => item.status === 'fulfilled').length;
108
+ const rejected = result.value.results.filter((item) => item.status === 'rejected').length;
109
+ console.log(`batch complete: fulfilled=${fulfilled} rejected=${rejected}`);
110
+ } finally {
111
+ await releaseLock();
112
+ }
113
+ }
114
+
115
+ function parseArgs(argv) {
116
+ const opts = {
117
+ runner: process.env.PUBLIC_LEADS_BATCH_RUNNER || process.env.LEAD_HARNESS_BATCH_RUNNER || 'opencode',
118
+ parallel: 1,
119
+ allowUnsafeWorkers: envFlag('PUBLIC_LEADS_ALLOW_UNSAFE_WORKERS') || envFlag('LEAD_HARNESS_ALLOW_UNSAFE_WORKERS'),
120
+ dryRun: false,
121
+ retryFailed: false,
122
+ startFrom: 0,
123
+ maxRetries: 2,
124
+ workflowId: 'public-leads-batch',
125
+ };
126
+
127
+ for (let i = 0; i < argv.length; i++) {
128
+ const arg = argv[i];
129
+ const next = () => {
130
+ i += 1;
131
+ if (i >= argv.length) throw new Error(`missing value for ${arg}`);
132
+ return argv[i];
133
+ };
134
+ if (arg === '--runner') opts.runner = next();
135
+ else if (arg === '--parallel') opts.parallel = boundedParallel(next(), '--parallel');
136
+ else if (arg === '--allow-unsafe-workers') opts.allowUnsafeWorkers = true;
137
+ else if (arg === '--dry-run') opts.dryRun = true;
138
+ else if (arg === '--retry-failed') opts.retryFailed = true;
139
+ else if (arg === '--start-from') opts.startFrom = nonNegativeInt(next(), '--start-from');
140
+ else if (arg === '--max-retries') opts.maxRetries = positiveInt(next(), '--max-retries');
141
+ else if (arg === '--workflow-id') opts.workflowId = sanitizeWorkflowId(next());
142
+ else if (arg === '-h' || arg === '--help') opts.help = true;
143
+ else throw new Error(`unknown option: ${arg}`);
144
+ }
145
+
146
+ opts.runner = parseRunner(opts.runner);
147
+ return opts;
148
+ }
149
+
150
+ async function checkPrerequisites(opts) {
151
+ if (!existsSync(INPUT_FILE)) throw new Error(`${INPUT_FILE} not found. Create batch/batch-input.tsv first.`);
152
+ if (!existsSync(PROMPT_FILE)) throw new Error(`${PROMPT_FILE} not found.`);
153
+ await ensureDir(LOGS_DIR);
154
+ await ensureDir(WORKFLOW_DIR);
155
+
156
+ if (!opts.dryRun) {
157
+ const result = spawnSync(workerCommandName(opts.runner), ['--help'], { stdio: 'ignore' });
158
+ if (result.error?.code === 'ENOENT') {
159
+ throw new Error(`'${workerCommandName(opts.runner)}' CLI not found in PATH`);
160
+ }
161
+ }
162
+ }
163
+
164
+ async function acquirePidLock(opts) {
165
+ if (opts.dryRun) return async () => {};
166
+ if (existsSync(LOCK_FILE)) {
167
+ const oldPid = (await readTextIfExists(LOCK_FILE)).trim();
168
+ if (oldPid) {
169
+ try {
170
+ process.kill(Number(oldPid), 0);
171
+ throw new Error(`another batch runner is already running (PID ${oldPid})`);
172
+ } catch (error) {
173
+ if (error.code !== 'ESRCH') throw error;
174
+ }
175
+ }
176
+ await rm(LOCK_FILE, { force: true });
177
+ }
178
+ await ensureDir(dirname(LOCK_FILE));
179
+ await writeFile(LOCK_FILE, String(process.pid), 'utf8');
180
+ return async () => {
181
+ await rm(LOCK_FILE, { force: true });
182
+ };
183
+ }
184
+
185
+ async function initState() {
186
+ if (existsSync(STATE_FILE)) return;
187
+ await ensureDir(dirname(STATE_FILE));
188
+ await writeFile(STATE_FILE, `${STATE_HEADER}\n`, 'utf8');
189
+ }
190
+
191
+ async function readInputDomains() {
192
+ const content = await readFile(INPUT_FILE, 'utf8');
193
+ const rows = [];
194
+ for (const line of content.split(/\r?\n/)) {
195
+ if (!line.trim()) continue;
196
+ const parts = line.split('\t');
197
+ if (parts[0] === 'id') continue;
198
+ const id = cell(parts[0], '');
199
+ const domain = normalizeDomain(parts[1]);
200
+ if (!id || !domain) continue;
201
+ rows.push({
202
+ id,
203
+ domain,
204
+ company: cell(parts[2]),
205
+ notes: cell(parts.slice(3).join(' ')),
206
+ });
207
+ }
208
+ return rows;
209
+ }
210
+
211
+ async function readState() {
212
+ await initState();
213
+ const content = await readFile(STATE_FILE, 'utf8');
214
+ const rows = new Map();
215
+ for (const line of content.split(/\r?\n/)) {
216
+ if (!line.trim()) continue;
217
+ const parts = line.split('\t');
218
+ if (parts[0] === 'id') continue;
219
+ rows.set(parts[0], normalizeStateRow({
220
+ id: parts[0],
221
+ domain: parts[1],
222
+ status: parts[2],
223
+ started_at: parts[3],
224
+ completed_at: parts[4],
225
+ artifact: parts[5],
226
+ lead_count: parts[6],
227
+ error: parts[7],
228
+ retries: parts[8],
229
+ }));
230
+ }
231
+ return rows;
232
+ }
233
+
234
+ async function writeState(rows) {
235
+ const lines = [STATE_HEADER];
236
+ for (const row of [...rows.values()].sort((a, b) => Number(a.id) - Number(b.id))) {
237
+ lines.push([
238
+ row.id,
239
+ row.domain,
240
+ row.status,
241
+ row.started_at,
242
+ row.completed_at,
243
+ row.artifact,
244
+ row.lead_count,
245
+ row.error,
246
+ row.retries,
247
+ ].map(cell).join('\t'));
248
+ }
249
+ await writeFile(STATE_FILE, `${lines.join('\n')}\n`, 'utf8');
250
+ }
251
+
252
+ async function updateStateRow(workflow, row) {
253
+ return workflow.withMutex('batch-state', async () => {
254
+ const rows = await readState();
255
+ const current = rows.get(row.id) || {};
256
+ const next = normalizeStateRow({ ...current, ...row });
257
+ rows.set(next.id, next);
258
+ await writeState(rows);
259
+ return next;
260
+ });
261
+ }
262
+
263
+ function selectPending(domains, rows, opts) {
264
+ const pending = [];
265
+ for (const item of domains) {
266
+ const numericId = Number.parseInt(item.id, 10);
267
+ if (!Number.isNaN(numericId) && numericId < opts.startFrom) continue;
268
+ const status = rows.get(item.id)?.status || 'none';
269
+ const retries = retriesFor(rows, item.id);
270
+ if (opts.retryFailed) {
271
+ if (status !== 'failed') continue;
272
+ if (retries >= opts.maxRetries) continue;
273
+ } else if (status === 'completed') {
274
+ continue;
275
+ } else if (status === 'failed' && retries >= opts.maxRetries) {
276
+ continue;
277
+ }
278
+ pending.push(item);
279
+ }
280
+ return pending;
281
+ }
282
+
283
+ async function processDomain(workflow, item, opts) {
284
+ const startedAt = nowIso();
285
+ const logFile = join(LOGS_DIR, `domain-${item.id}.log`);
286
+ const artifact = `batch/lead-results-${item.id}.json`;
287
+ const rows = await readState();
288
+ const retries = retriesFor(rows, item.id);
289
+
290
+ await updateStateRow(workflow, {
291
+ id: item.id,
292
+ domain: item.domain,
293
+ status: 'processing',
294
+ started_at: startedAt,
295
+ completed_at: '-',
296
+ artifact,
297
+ lead_count: '-',
298
+ error: '-',
299
+ retries,
300
+ });
301
+
302
+ const prompt = buildWorkerPrompt(item, artifact);
303
+ const run = await withWorkerLiveness(workflow, item, logFile, () => runWorker(opts, prompt, logFile));
304
+ const statuses = parseStatusLines(run.output);
305
+ const status = statuses.get(item.id);
306
+
307
+ if (run.exitCode !== 0 || !status || status.status !== 'completed') {
308
+ const message = status?.error || `worker failed with exit ${run.exitCode}`;
309
+ await updateStateRow(workflow, {
310
+ id: item.id,
311
+ status: 'failed',
312
+ completed_at: nowIso(),
313
+ error: message,
314
+ retries: retries + 1,
315
+ });
316
+ return { id: item.id, status: 'failed', error: message };
317
+ }
318
+
319
+ const manifest = spawnSync(process.execPath, [
320
+ join(PKG_ROOT, 'scripts/manifest.mjs'),
321
+ '--input',
322
+ status.artifact || artifact,
323
+ ], {
324
+ cwd: PROJECT_DIR,
325
+ env: {
326
+ ...process.env,
327
+ PUBLIC_LEADS_PROJECT: PROJECT_DIR,
328
+ PUBLIC_LEADS_ROOT: PKG_ROOT,
329
+ LEAD_HARNESS_PROJECT: PROJECT_DIR,
330
+ LEAD_HARNESS_ROOT: PKG_ROOT,
331
+ },
332
+ encoding: 'utf8',
333
+ });
334
+
335
+ if (manifest.status !== 0) {
336
+ const message = (manifest.stderr || manifest.stdout || 'manifest update failed').trim();
337
+ await updateStateRow(workflow, {
338
+ id: item.id,
339
+ status: 'failed',
340
+ completed_at: nowIso(),
341
+ error: message,
342
+ retries: retries + 1,
343
+ });
344
+ return { id: item.id, status: 'failed', error: message };
345
+ }
346
+
347
+ await updateStateRow(workflow, {
348
+ id: item.id,
349
+ status: 'completed',
350
+ completed_at: nowIso(),
351
+ artifact: status.artifact || artifact,
352
+ lead_count: String(status.leadCount ?? 0),
353
+ error: '-',
354
+ retries,
355
+ });
356
+ return { id: item.id, status: 'completed', artifact: status.artifact || artifact, leadCount: status.leadCount ?? 0 };
357
+ }
358
+
359
+ function buildWorkerPrompt(item, artifact) {
360
+ return `Process this assigned public-leads domain.
361
+
362
+ Assignment:
363
+ ${JSON.stringify({ ...item, artifact }, null, 2)}
364
+
365
+ Write the artifact exactly to ${artifact}. Validate it with:
366
+ npx public-leads validate --input ${artifact}
367
+
368
+ Finish with one JSON status line:
369
+ {"id":"${item.id}","status":"completed|failed","domain":"${item.domain}","leadCount":0,"artifact":"${artifact}","error":null}`;
370
+ }
371
+
372
+ async function runWorker(opts, prompt, logFile) {
373
+ if (opts.runner === 'codex') return runCodex(prompt, logFile, opts);
374
+ return runOpencode(prompt, logFile, opts);
375
+ }
376
+
377
+ async function runOpencode(prompt, logFile, opts) {
378
+ await ensureDir(dirname(logFile));
379
+ return new Promise((resolveRun) => {
380
+ const args = ['run'];
381
+ if (opts.allowUnsafeWorkers) args.push('--dangerously-skip-permissions');
382
+ args.push('--file', PROMPT_FILE, prompt);
383
+ const child = spawn('opencode', args, {
384
+ cwd: PROJECT_DIR,
385
+ env: {
386
+ ...process.env,
387
+ PUBLIC_LEADS_PROJECT: PROJECT_DIR,
388
+ LEAD_HARNESS_PROJECT: PROJECT_DIR,
389
+ },
390
+ stdio: ['ignore', 'pipe', 'pipe'],
391
+ });
392
+ collectChild(child, logFile, resolveRun);
393
+ });
394
+ }
395
+
396
+ async function runCodex(prompt, logFile, opts) {
397
+ await ensureDir(dirname(logFile));
398
+ const basePrompt = await readTextIfExists(PROMPT_FILE);
399
+ return new Promise((resolveRun) => {
400
+ const args = ['exec'];
401
+ if (opts.allowUnsafeWorkers) args.push('--dangerously-bypass-approvals-and-sandbox');
402
+ args.push('-C', PROJECT_DIR, `${basePrompt.trim()}\n\n${prompt}`);
403
+ const child = spawn('codex', args, {
404
+ cwd: PROJECT_DIR,
405
+ env: {
406
+ ...process.env,
407
+ PUBLIC_LEADS_PROJECT: PROJECT_DIR,
408
+ LEAD_HARNESS_PROJECT: PROJECT_DIR,
409
+ },
410
+ stdio: ['ignore', 'pipe', 'pipe'],
411
+ });
412
+ collectChild(child, logFile, resolveRun);
413
+ });
414
+ }
415
+
416
+ function collectChild(child, logFile, resolveRun) {
417
+ const chunks = [];
418
+ child.stdout.on('data', (chunk) => chunks.push(chunk));
419
+ child.stderr.on('data', (chunk) => chunks.push(chunk));
420
+ child.on('error', (error) => chunks.push(Buffer.from(`\n${error.stack || error.message}\n`)));
421
+ child.on('close', async (code) => {
422
+ const output = Buffer.concat(chunks).toString('utf8');
423
+ await writeFile(logFile, output, 'utf8');
424
+ resolveRun({ exitCode: code ?? 1, output });
425
+ });
426
+ }
427
+
428
+ async function withWorkerLiveness(workflow, item, logFile, run) {
429
+ const key = `worker:${item.id}`;
430
+ const holder = `${process.pid}:${item.id}`;
431
+ await workflow.touchLease(key, { holder, ttlMs: 120_000, detail: { domain: item.domain, log: rel(logFile), phase: 'starting' } });
432
+ await workflow.heartbeat(key, { domain: item.domain, log: rel(logFile), phase: 'starting' });
433
+ const timer = setInterval(() => {
434
+ workflow.touchLease(key, { holder, ttlMs: 120_000, detail: { domain: item.domain, log: rel(logFile), phase: 'running' } }).catch(() => {});
435
+ workflow.heartbeat(key, { domain: item.domain, log: rel(logFile), phase: 'running' }).catch(() => {});
436
+ }, 30_000);
437
+ timer.unref?.();
438
+ try {
439
+ return await run();
440
+ } finally {
441
+ clearInterval(timer);
442
+ await workflow.heartbeat(key, { domain: item.domain, log: rel(logFile), phase: 'finished' }).catch(() => {});
443
+ await workflow.releaseLease(key, holder).catch(() => {});
444
+ }
445
+ }
446
+
447
+ function parseStatusLines(output) {
448
+ const statuses = new Map();
449
+ for (const line of output.split('\n')) {
450
+ const start = line.indexOf('{');
451
+ const end = line.lastIndexOf('}');
452
+ if (start === -1 || end <= start) continue;
453
+ try {
454
+ const parsed = JSON.parse(line.slice(start, end + 1));
455
+ if (parsed?.id && parsed?.status) statuses.set(String(parsed.id), parsed);
456
+ } catch {
457
+ // Worker logs can contain non-JSON diagnostics.
458
+ }
459
+ }
460
+ return statuses;
461
+ }
462
+
463
+ function parseRunner(value) {
464
+ const runner = String(value || '').trim().toLowerCase();
465
+ if (runner === 'opencode' || runner === 'codex') return runner;
466
+ throw new Error('--runner must be opencode or codex');
467
+ }
468
+
469
+ function workerCommandName(runner) {
470
+ return runner === 'codex' ? 'codex' : 'opencode';
471
+ }
472
+
473
+ function normalizeStateRow(row) {
474
+ return {
475
+ id: cell(row.id, ''),
476
+ domain: normalizeDomain(row.domain),
477
+ status: cell(row.status, 'none'),
478
+ started_at: cell(row.started_at),
479
+ completed_at: cell(row.completed_at),
480
+ artifact: cell(row.artifact),
481
+ lead_count: cell(row.lead_count),
482
+ error: cell(row.error),
483
+ retries: String(nonNegativeInt(row.retries || 0, 'retries')),
484
+ };
485
+ }
486
+
487
+ function retriesFor(rows, id) {
488
+ const n = Number.parseInt(rows.get(id)?.retries || '0', 10);
489
+ return Number.isInteger(n) && n >= 0 ? n : 0;
490
+ }
491
+
492
+ function positiveInt(value, label) {
493
+ const n = Number.parseInt(value, 10);
494
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${label} must be a positive integer`);
495
+ return n;
496
+ }
497
+
498
+ function boundedParallel(value, label) {
499
+ const n = positiveInt(value, label);
500
+ if (n > MAX_PARALLEL_WORKERS) {
501
+ throw new Error(`${label} must be ${MAX_PARALLEL_WORKERS} or less; split larger queues into multiple rounds`);
502
+ }
503
+ return n;
504
+ }
505
+
506
+ function nonNegativeInt(value, label) {
507
+ const n = Number.parseInt(value, 10);
508
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${label} must be a non-negative integer`);
509
+ return n;
510
+ }
511
+
512
+ function envFlag(name) {
513
+ return /^(1|true|yes)$/i.test(String(process.env[name] || '').trim());
514
+ }
515
+
516
+ function sanitizeWorkflowId(value) {
517
+ const clean = String(value || '').trim().replace(/[^a-zA-Z0-9._:-]+/g, '-');
518
+ if (!clean) throw new Error('--workflow-id cannot be empty');
519
+ return clean;
520
+ }
521
+
522
+ function normalizeDomain(value) {
523
+ let raw = cell(value, '').toLowerCase();
524
+ if (!raw) return '';
525
+ if (!raw.includes('://')) raw = `https://${raw}`;
526
+ try {
527
+ raw = new URL(raw).hostname;
528
+ } catch {
529
+ raw = raw.replace(/^https?:\/\//, '').split('/')[0];
530
+ }
531
+ return raw.replace(/^www\./, '').replace(/\.$/, '').trim();
532
+ }
533
+
534
+ function cell(value, fallback = '-') {
535
+ const text = value === undefined || value === null || value === '' ? fallback : String(value);
536
+ return text.replace(/[\t\r\n]+/g, ' ').trim() || fallback;
537
+ }
538
+
539
+ function hash(value) {
540
+ return createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
541
+ }
542
+
543
+ function nowIso() {
544
+ return new Date().toISOString();
545
+ }
546
+
547
+ function rel(path) {
548
+ return path.startsWith(PROJECT_DIR) ? path.slice(PROJECT_DIR.length + 1) : path;
549
+ }
550
+
551
+ async function ensureDir(path) {
552
+ await mkdir(path, { recursive: true });
553
+ }
554
+
555
+ async function readTextIfExists(path) {
556
+ if (!existsSync(path)) return '';
557
+ return readFile(path, 'utf8');
558
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { pathToFileURL } from 'url';
5
+ import { crawlDomains } from '../lib/leadharness-crawler.mjs';
6
+ import {
7
+ loadProfileConfig,
8
+ parseArgs,
9
+ relativeProjectPath,
10
+ resolveProjectPath,
11
+ validatePayload,
12
+ writeJson,
13
+ } from '../lib/leadharness-leads.mjs';
14
+
15
+ const USAGE = `public-leads crawl -- crawl public company pages into a lead artifact
16
+
17
+ Usage:
18
+ public-leads crawl [--domain example.com | --domains example.com,example.org]
19
+ [--input data/domains.tsv] [--out data/lead-results.json]
20
+ [--max-pages 10] [--min-confidence 30]
21
+ [--include-blocked] [--allow-empty] [--json]
22
+
23
+ When no domain or input is supplied, the first existing file is used:
24
+ data/domains.tsv, then data/pipeline.md
25
+ `;
26
+
27
+ if (isDirectRun()) {
28
+ main().catch((error) => {
29
+ console.error(error instanceof Error ? error.message : String(error));
30
+ process.exit(1);
31
+ });
32
+ }
33
+
34
+ async function main() {
35
+ const opts = parseArgs(process.argv.slice(2));
36
+ if (opts.help) {
37
+ console.log(USAGE);
38
+ process.exit(0);
39
+ }
40
+
41
+ const profile = loadProfileConfig();
42
+ const domains = readRequestedDomains(opts);
43
+ const out = opts.out || 'data/lead-results.json';
44
+ const payload = await crawlDomains(domains, {
45
+ maxPages: opts.maxPages || profile.maxPages,
46
+ minConfidence: opts.minConfidence || profile.minConfidence,
47
+ userAgent: opts.userAgent || profile.userAgent,
48
+ includeBlocked: Boolean(opts.includeBlocked),
49
+ delayMs: opts.delayMs,
50
+ timeoutMs: opts.timeoutMs,
51
+ jobId: opts.jobId,
52
+ });
53
+ writeJson(resolveProjectPath(out), payload);
54
+
55
+ const validation = validatePayload(payload, { allowEmpty: Boolean(opts.allowEmpty) });
56
+ if (opts.json) {
57
+ console.log(JSON.stringify({ output: out, validation, summary: validation.summary }, null, 2));
58
+ } else {
59
+ console.log(`crawl: wrote ${relativeProjectPath(resolveProjectPath(out))}`);
60
+ console.log(`domains=${validation.summary.domainCount} leads=${validation.summary.leadCount} results=${validation.summary.resultCount} errors=${payload.errors.length}`);
61
+ }
62
+
63
+ if (!validation.ok) {
64
+ for (const item of validation.issues.filter((issue) => issue.severity === 'error')) {
65
+ console.error(`error: ${item.path}: ${item.code}: ${item.message}`);
66
+ }
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ export function readRequestedDomains(opts) {
72
+ const inline = [
73
+ ...splitDomainList(opts.domain),
74
+ ...splitDomainList(opts.domains),
75
+ ...splitDomainList(opts._ || []),
76
+ ];
77
+ if (inline.length > 0) return inline;
78
+
79
+ const input = opts.input || firstExisting(['data/domains.tsv', 'data/pipeline.md']);
80
+ if (!input) {
81
+ throw new Error('no domains supplied; pass --domain, --domains, or create data/domains.tsv');
82
+ }
83
+ return readDomainsFile(input);
84
+ }
85
+
86
+ export function readDomainsFile(path) {
87
+ const abs = resolveProjectPath(path);
88
+ if (!existsSync(abs)) throw new Error(`input not found: ${path}`);
89
+ const text = readFileSync(abs, 'utf8');
90
+ if (path.endsWith('.md')) return readPipelineMarkdown(text);
91
+ return readDomainsTSV(text);
92
+ }
93
+
94
+ function readDomainsTSV(text) {
95
+ const domains = [];
96
+ for (const line of text.split(/\r?\n/)) {
97
+ if (!line.trim()) continue;
98
+ const parts = line.split('\t');
99
+ if (/^domain$/i.test(parts[0])) continue;
100
+ const domain = (parts[0] || '').trim();
101
+ if (domain) domains.push(domain);
102
+ }
103
+ return domains;
104
+ }
105
+
106
+ function readPipelineMarkdown(text) {
107
+ const domains = [];
108
+ for (const line of text.split(/\r?\n/)) {
109
+ const match = line.match(/^\s*-\s*\[[ xX-]\]\s*([^|\s]+)(?:\s*\||\s*$)/);
110
+ if (match?.[1]) domains.push(match[1]);
111
+ }
112
+ return domains;
113
+ }
114
+
115
+ function splitDomainList(value) {
116
+ const values = Array.isArray(value) ? value : [value];
117
+ return values
118
+ .flatMap((item) => String(item || '').split(/[,\s]+/))
119
+ .map((item) => item.trim())
120
+ .filter(Boolean);
121
+ }
122
+
123
+ function firstExisting(paths) {
124
+ return paths.find((path) => existsSync(resolveProjectPath(path))) || '';
125
+ }
126
+
127
+ function isDirectRun() {
128
+ return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
129
+ }