Add local lead campaign runner - #224
lumenstech wants to merge 28 commits into
Conversation
|
Someone is attempting to deploy a commit to the Comp AI - PoC Team on Vercel. A member of the Team first needs to authorize it. |
|
Retargeted this onto
Nothing is wrong with your branch. If the diff now shows commits that are already on git fetch origin main
git rebase origin/main
git push --force-with-lease |
|
Approve tool call: github__addPullRequestComment
Answer by mentioning me in a reply, e.g. |
There was a problem hiding this comment.
40 issues found across 33 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/agent/agent/cli/local-campaign-worker.ts">
<violation number="1" location="apps/agent/agent/cli/local-campaign-worker.ts:23">
P1: When an optional flag is omitted, `argument` returns the subcommand name instead of `undefined`. This makes the documented default `run`, `list`, and `export` flows fail; guard the positional lookup when `index < 0`.</violation>
</file>
<file name="packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql">
<violation number="1" location="packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql:166">
P2: An approval can retain a dangling `memberId` or point to a nonexistent member because this column has no foreign key. Add a nullable foreign key to `campaign_member(id)` with `ON DELETE SET NULL`.</violation>
<violation number="2" location="packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql:234">
P1: When two projects ingest the same source triple, this global index makes `IngestService.signal` overwrite the first project's record and business-unit assignment. Scope source identity by `businessUnitId` and update the conflict lookup and upsert to use that scope.</violation>
</file>
<file name="apps/agent/agent/lib/local-lead-extraction.ts">
<violation number="1" location="apps/agent/agent/lib/local-lead-extraction.ts:134">
P1: A DNS-rebinding source can pass `assertPublicHostname` and then make the fetch connection to a private address. Resolve and pin the validated address for the request, or use an HTTP client with DNS-rebinding protection.</violation>
<violation number="2" location="apps/agent/agent/lib/local-lead-extraction.ts:135">
P2: Valid IPv6-literal source URLs are rejected because `URL.hostname` retains brackets while `assertPublicHostname` passes them directly to DNS lookup. Strip the brackets for DNS validation while retaining the original URL for fetch.</violation>
<violation number="3" location="apps/agent/agent/lib/local-lead-extraction.ts:139">
P1: When a public source redirects to a private URL, `fetchLocalLeadSource` follows it without rechecking the destination, bypassing the SSRF guard. Disable redirects or validate every redirect target before fetching.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-extraction.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-extraction.ts:84">
P1: An Ollama response can invent a company and repeat it in `evidence_excerpt`, and this call accepts it because validation never sees `text`. Pass the source text into parsing and require the evidence and extracted values to occur in that source before staging.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-staging.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-staging.ts:21">
P2: When the repository path contains spaces, the default staging path points to a percent-encoded directory instead of the repository. Convert `import.meta.url` with `fileURLToPath` before calling `dirname`.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-staging.ts:64">
P1: When multiple seed pages share a host, every later lead becomes `duplicate`, even when it represents a different company. Deduplicate by the extracted company website or another lead identity instead of the source-page domain.</violation>
<violation number="3" location="apps/agent/agent/lib/local-campaign-staging.ts:111">
P1: When a lead is a duplicate, `stageCampaignLead` still appends it. Repeated runs therefore grow staging and exports with duplicate rows despite the duplicate count.</violation>
</file>
<file name="apps/api/src/ingest/signal-qualification.service.ts">
<violation number="1" location="apps/api/src/ingest/signal-qualification.service.ts:108">
P2: The explicit amount has no maximum and `estimated_value_usd` is unconstrained; negative estimates can be persisted, while values above `DECIMAL(14,2)` make promotion fail. Validate both sources against the same nonnegative maximum before writing.</violation>
<violation number="2" location="apps/api/src/ingest/signal-qualification.service.ts:113">
P1: Two concurrent promotions can both observe no opportunity mapping and create duplicate canonical opportunities and visible deals. Serialize promotion per source record or use a transactional, idempotent upsert before creating these entities.</violation>
<violation number="3" location="apps/api/src/ingest/signal-qualification.service.ts:147">
P2: After a deal is purged, its mapping retains the deleted ID, so `promote` skips deal creation and returns a stale `dealId`. Verify the mapped deal exists or clear stale mappings before reusing `applicationId`.</violation>
<violation number="4" location="apps/api/src/ingest/signal-qualification.service.ts:149">
P1: When `createDeal` is true but the score is 70–84, the company lacks an application mapping, or `ownerId` is missing, `promote` writes an opportunity before throwing. Validate these preconditions before the upsert and make the canonical/deal/mapping writes atomic.</violation>
</file>
<file name="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx">
<violation number="1" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:217">
P1: Switching rows keeps `score` and `ownerId` from the previous sheet. Saving a score or deal then sends values for the wrong signal. Key `SignalReviewSheet` by `selected.sourceRecordId` or reset both states when `row` changes.</violation>
<violation number="2" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:220">
P2: Refetching updates the table, not `selected`. After resolving or qualifying, stale `row.mapped` and `row.signalScore` keep promotion disabled until the user reopens the sheet. Replace `selected` with the refreshed row after `signals.refetch()`.</violation>
<violation number="3" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:515">
P2: Keep existing score sends no components. The qualification service then stores `qualification.components` as `null`, erasing the transparent breakdown. Preserve the existing qualification instead.</violation>
</file>
<file name="apps/api/src/ingest/ingest.module.ts">
<violation number="1" location="apps/api/src/ingest/ingest.module.ts:9">
P1: The ingest procedures will not be registered with `AppRouterHost` because `IngestModule` does not import `TrpcModule`. Add `TrpcModule` to this module’s imports and add its import declaration so the tRPC and REST bridge endpoints are exposed at runtime.</violation>
</file>
<file name="packages/db/prisma/schema.prisma">
<violation number="1" location="packages/db/prisma/schema.prisma:933">
P1: This relation does not require the deal’s unit to match its required company. Signal promotion sets the deal unit independently, creating cross-unit CRM records. Validate the match or enforce a composite relation.</violation>
<violation number="2" location="packages/db/prisma/schema.prisma:1700">
P1: These foreign keys check target IDs, not business-unit ownership. Cross-unit opportunities, source records, and company-person links can enter the database. Enforce same-unit composite references or validate the invariant before writes.</violation>
<violation number="3" location="packages/db/prisma/schema.prisma:1790">
P2: When either target ID is null, PostgreSQL allows duplicate members despite this unique index. Add partial unique constraints and require at least one target.</violation>
</file>
<file name="apps/api/src/ingest/ingest.service.ts">
<violation number="1" location="apps/api/src/ingest/ingest.service.ts:96">
P2: When an ingest caller supplies `entity` or `signal_score` only inside `payload`, `signal` overwrites those values with `null`. Preserve payload values when the optional top-level overrides are `undefined`, while still honoring explicit overrides.</violation>
<violation number="2" location="apps/api/src/ingest/ingest.service.ts:139">
P2: When a stored signal contains a non-numeric score string, `inbox` fails with a database cast error. Extract scores with a safe numeric check before casting, consistently in the SELECT, filter, and ordering expressions.</violation>
<violation number="3" location="apps/api/src/ingest/ingest.service.ts:157">
P2: When callers set `SignalInboxInput.source`, `inbox` still returns records from every source. Add a source-system predicate alongside the project predicate.</violation>
<violation number="4" location="apps/api/src/ingest/ingest.service.ts:181">
P2: When an existing source record has no `observedAt`, `inbox` throws on `.toISOString()` instead of returning the record. Handle nullable timestamps and align `signalInboxItem.observedAt` with the database schema.</violation>
<violation number="5" location="apps/api/src/ingest/ingest.service.ts:211">
P2: When more than 20 companies match a common entity name, the unordered SQL limit can hide the best candidate and the later sort cannot recover it. Order by exact domain/name (and business-unit preference) before applying `LIMIT 20`.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-schema.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-schema.ts:13">
P2: A campaign with `source_types: ["search"]` validates but the runner has no search ingestion. Reject `search` until the provider exists.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-schema.ts:19">
P2: When `required_fields` contains a typo or unsupported name, validation accepts it and `runLocalCampaign` treats it as satisfied. Restrict this array to extracted lead fields so misspelled requirements cannot produce `new` leads.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-scoring.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-scoring.ts:20">
P2: When Ollama puts a campaign term only in `project_fit_reason`, this line lets model-generated rationale create or suppress fit without source-backed evidence. Build `haystack` from validated fields and `evidence_excerpt`, or validate `project_fit_reason` before scoring.</violation>
</file>
<file name="apps/agent/test/local-lead-extraction.spec.ts">
<violation number="1" location="apps/agent/test/local-lead-extraction.spec.ts:85">
P2: The byte-limit test does not await its `.rejects` matcher, so it can finish without observing the assertion result. Await the matcher to make failures reliably fail this test.</violation>
</file>
<file name="apps/agent/agent/lib/local-lead-staging.ts">
<violation number="1" location="apps/agent/agent/lib/local-lead-staging.ts:17">
P2: A symlink under `var/local-leads` bypasses this lexical check, and `appendFile` follows it. Canonicalize the directory and file path, or reject symlinked path components, before writing lead data.</violation>
<violation number="2" location="apps/agent/agent/lib/local-lead-staging.ts:40">
P2: Concurrent staging processes create duplicate hashes because the read-and-append sequence is not atomic. Add an inter-process lock or use an atomic update mechanism around deduplication and append.</violation>
<violation number="3" location="apps/agent/agent/lib/local-lead-staging.ts:53">
P2: When the existing JSONL file has no final newline, this append joins two objects and breaks future staging. Insert a newline before the new record when `existing` is non-empty and lacks `\n`.</violation>
</file>
<file name="apps/api/src/ingest/ingest.contracts.ts">
<violation number="1" location="apps/api/src/ingest/ingest.contracts.ts:30">
P2: When a signal supplies a `javascript:` or `data:` source URL, `z.string().url()` accepts it and the inbox renders it as a clickable link. Restrict `sourceUrl` to `http:` and `https:` before storing or rendering it.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-runner.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-runner.ts:104">
P2: On reruns, this condition ignores non-duplicate leads already staged for the campaign. Count existing campaign leads before processing, or enforce `max_companies` against the persistent staging set.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-export.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-export.ts:6">
P2: csvValue does not guard against CSV/formula injection. Lead fields (company_name, website, contact_name, etc.) come from scraped pages and LLM extraction — untrusted input — and this export is meant to be opened in a spreadsheet. A value such as `=HYPERLINK("http://evil","x")` or `+cmd|'/C calc'!A0` is evaluated as a formula by Excel/LibreOffice/Google Sheets when the file opens, even though it is valid CSV. Quote-escaping does not prevent this: Excel strips the quotes and still evaluates the leading `=`. Prefix values that start with `=`, `+`, `-`, or `@` with a literal `'` before writing the field.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-cache.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-cache.ts:23">
P2: As the cache grows, each seed rereads and parses the entire `pages.jsonl`, making larger campaigns increasingly slow and memory-intensive. Index the cache once per run or maintain a URL index instead of rereading the full file for every lookup.</violation>
</file>
<file name="apps/agent/campaigns/partwall-2027.json">
<violation number="1" location="apps/agent/campaigns/partwall-2027.json:5">
P2: Leads outside the configured geography are staged because the runner never applies `campaign.geography`. Filter or score locations against the campaign geography before staging.</violation>
</file>
<file name="apps/agent/agent/cli/local-lead-worker.ts">
<violation number="1" location="apps/agent/agent/cli/local-lead-worker.ts:12">
P2: When a value-taking flag has no value, `valueAfter` consumes the next option or returns `undefined`, causing malformed invocations to run with bogus input or unexpected defaults. Reject missing values before starting extraction or staging.</violation>
</file>
<file name="apps/agent/test/local-lead-staging.spec.ts">
<violation number="1" location="apps/agent/test/local-lead-staging.spec.ts:54">
P2: Await this `.rejects` assertion. Without `await`, the test can pass without enforcing the rejection, so a regression that resolves successfully may go undetected.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| return ( | ||
| args | ||
| .find((value) => value.startsWith(`${flag}=`)) | ||
| ?.slice(flag.length + 1) ?? args[index + 1] |
There was a problem hiding this comment.
P1: When an optional flag is omitted, argument returns the subcommand name instead of undefined. This makes the documented default run, list, and export flows fail; guard the positional lookup when index < 0.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/cli/local-campaign-worker.ts, line 23:
<comment>When an optional flag is omitted, `argument` returns the subcommand name instead of `undefined`. This makes the documented default `run`, `list`, and `export` flows fail; guard the positional lookup when `index < 0`.</comment>
<file context>
@@ -0,0 +1,90 @@
+ return (
+ args
+ .find((value) => value.startsWith(`${flag}=`))
+ ?.slice(flag.length + 1) ?? args[index + 1]
+ );
+}
</file context>
| CREATE INDEX "source_record_businessUnitId_sourceType_idx" ON "source_record"("businessUnitId", "sourceType"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "source_record_sourceSystem_sourceType_sourceId_key" ON "source_record"("sourceSystem", "sourceType", "sourceId"); |
There was a problem hiding this comment.
P1: When two projects ingest the same source triple, this global index makes IngestService.signal overwrite the first project's record and business-unit assignment. Scope source identity by businessUnitId and update the conflict lookup and upsert to use that scope.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql, line 234:
<comment>When two projects ingest the same source triple, this global index makes `IngestService.signal` overwrite the first project's record and business-unit assignment. Scope source identity by `businessUnitId` and update the conflict lookup and upsert to use that scope.</comment>
<file context>
@@ -0,0 +1,318 @@
+CREATE INDEX "source_record_businessUnitId_sourceType_idx" ON "source_record"("businessUnitId", "sourceType");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "source_record_sourceSystem_sourceType_sourceId_key" ON "source_record"("sourceSystem", "sourceType", "sourceId");
+
+-- CreateIndex
</file context>
| options.lookup ?? resolveHostname, | ||
| ); | ||
|
|
||
| const response = await fetchImpl(url, { |
There was a problem hiding this comment.
P1: When a public source redirects to a private URL, fetchLocalLeadSource follows it without rechecking the destination, bypassing the SSRF guard. Disable redirects or validate every redirect target before fetching.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-lead-extraction.ts, line 139:
<comment>When a public source redirects to a private URL, `fetchLocalLeadSource` follows it without rechecking the destination, bypassing the SSRF guard. Disable redirects or validate every redirect target before fetching.</comment>
<file context>
@@ -0,0 +1,261 @@
+ options.lookup ?? resolveHostname,
+ );
+
+ const response = await fetchImpl(url, {
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
+ headers: { accept: "text/html,text/plain;q=0.9" },
</file context>
| if (isBlockedHostname(parsed.hostname)) { | ||
| throw new Error("Local source URLs are blocked."); | ||
| } | ||
| await assertPublicHostname( |
There was a problem hiding this comment.
P1: A DNS-rebinding source can pass assertPublicHostname and then make the fetch connection to a private address. Resolve and pin the validated address for the request, or use an HTTP client with DNS-rebinding protection.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-lead-extraction.ts, line 134:
<comment>A DNS-rebinding source can pass `assertPublicHostname` and then make the fetch connection to a private address. Resolve and pin the validated address for the request, or use an HTTP client with DNS-rebinding protection.</comment>
<file context>
@@ -0,0 +1,261 @@
+ if (isBlockedHostname(parsed.hostname)) {
+ throw new Error("Local source URLs are blocked.");
+ }
+ await assertPublicHostname(
+ parsed.hostname,
+ options.lookup ?? resolveHostname,
</file context>
| text, | ||
| ].join("\n"); | ||
| const result = await client.generate({ model: LOCAL_CAMPAIGN_MODEL, prompt }); | ||
| const lead = parseLocalCampaignResponse(result.text, sourceUrl); |
There was a problem hiding this comment.
P1: An Ollama response can invent a company and repeat it in evidence_excerpt, and this call accepts it because validation never sees text. Pass the source text into parsing and require the evidence and extracted values to occur in that source before staging.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-extraction.ts, line 84:
<comment>An Ollama response can invent a company and repeat it in `evidence_excerpt`, and this call accepts it because validation never sees `text`. Pass the source text into parsing and require the evidence and extracted values to occur in that source before staging.</comment>
<file context>
@@ -0,0 +1,93 @@
+ text,
+ ].join("\n");
+ const result = await client.generate({ model: LOCAL_CAMPAIGN_MODEL, prompt });
+ const lead = parseLocalCampaignResponse(result.text, sourceUrl);
+ return {
+ ...lead,
</file context>
| "campaign_id": "partwall-trade-show-2027", | ||
| "project": "PartWall", | ||
| "target_customer_type": "trade show exhibitors and industrial display buyers", | ||
| "geography": ["United States", "Canada"], |
There was a problem hiding this comment.
P2: Leads outside the configured geography are staged because the runner never applies campaign.geography. Filter or score locations against the campaign geography before staging.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/campaigns/partwall-2027.json, line 5:
<comment>Leads outside the configured geography are staged because the runner never applies `campaign.geography`. Filter or score locations against the campaign geography before staging.</comment>
<file context>
@@ -0,0 +1,29 @@
+ "campaign_id": "partwall-trade-show-2027",
+ "project": "PartWall",
+ "target_customer_type": "trade show exhibitors and industrial display buyers",
+ "geography": ["United States", "Canada"],
+ "event_window": {
+ "start": "2027-03-01",
</file context>
|
|
||
| function valueAfter(args: string[], flag: string): string | undefined { | ||
| const index = args.indexOf(flag); | ||
| return index >= 0 ? args[index + 1] : undefined; |
There was a problem hiding this comment.
P2: When a value-taking flag has no value, valueAfter consumes the next option or returns undefined, causing malformed invocations to run with bogus input or unexpected defaults. Reject missing values before starting extraction or staging.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/cli/local-lead-worker.ts, line 12:
<comment>When a value-taking flag has no value, `valueAfter` consumes the next option or returns `undefined`, causing malformed invocations to run with bogus input or unexpected defaults. Reject missing values before starting extraction or staging.</comment>
<file context>
@@ -0,0 +1,72 @@
+
+function valueAfter(args: string[], flag: string): string | undefined {
+ const index = args.indexOf(flag);
+ return index >= 0 ? args[index + 1] : undefined;
+}
+
</file context>
| throw new Error("Local source URLs are blocked."); | ||
| } | ||
| await assertPublicHostname( | ||
| parsed.hostname, |
There was a problem hiding this comment.
P2: Valid IPv6-literal source URLs are rejected because URL.hostname retains brackets while assertPublicHostname passes them directly to DNS lookup. Strip the brackets for DNS validation while retaining the original URL for fetch.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-lead-extraction.ts, line 135:
<comment>Valid IPv6-literal source URLs are rejected because `URL.hostname` retains brackets while `assertPublicHostname` passes them directly to DNS lookup. Strip the brackets for DNS validation while retaining the original URL for fetch.</comment>
<file context>
@@ -0,0 +1,261 @@
+ throw new Error("Local source URLs are blocked.");
+ }
+ await assertPublicHostname(
+ parsed.hostname,
+ options.lookup ?? resolveHostname,
+ );
</file context>
| parsed.hostname, | |
| parsed.hostname.replace(/^\[|\]$/g, ""), |
| this.text(payload.subject) ?? | ||
| this.text(payload.message) ?? | ||
| "Qualified opportunity"; | ||
| const amount = input.amountUsd ?? this.number(payload.estimated_value_usd); |
There was a problem hiding this comment.
P2: The explicit amount has no maximum and estimated_value_usd is unconstrained; negative estimates can be persisted, while values above DECIMAL(14,2) make promotion fail. Validate both sources against the same nonnegative maximum before writing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/ingest/signal-qualification.service.ts, line 108:
<comment>The explicit amount has no maximum and `estimated_value_usd` is unconstrained; negative estimates can be persisted, while values above `DECIMAL(14,2)` make promotion fail. Validate both sources against the same nonnegative maximum before writing.</comment>
<file context>
@@ -0,0 +1,308 @@
+ this.text(payload.subject) ??
+ this.text(payload.message) ??
+ "Qualified opportunity";
+ const amount = input.amountUsd ?? this.number(payload.estimated_value_usd);
+ const stage = scored.score >= 85 ? "priority" : "qualified";
+
</file context>
| "https://example.com/acme", | ||
| { generate: async () => ({ text: response, latencyMs: 5 }) }, | ||
| ); | ||
| expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads"); |
There was a problem hiding this comment.
P2: Await this .rejects assertion. Without await, the test can pass without enforcing the rejection, so a regression that resolves successfully may go undetected.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/local-lead-staging.spec.ts, line 54:
<comment>Await this `.rejects` assertion. Without `await`, the test can pass without enforcing the rejection, so a regression that resolves successfully may go undetected.</comment>
<file context>
@@ -0,0 +1,56 @@
+ "https://example.com/acme",
+ { generate: async () => ({ text: response, latencyMs: 5 }) },
+ );
+ expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
+ });
+});
</file context>
| expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads"); | |
| await expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads"); |
Summary
Safety
Tests
Run
Results stage under:
apps/agent/var/local-leads/
Known Follow-ups
Add approved-review import into a local/test database.
Add search-provider ingestion after confirming an existing configured API key.
Keep using JSONL seed files for complex seed data because quoted CSV fields are rejected.
Repair or version the untracked deploy/ dependency separately.
After this PR, the next step should be operational, not another abstract foundation: run a small PartWall seed file with 20-50 real exhibitor/source URLs, export the staged leads, and inspect whether the scoring is producing usable prospects. That tells us whether the local worker is actually saving you ChatGPT usage in practice.
Summary by cubic
Adds an authenticated signal-ingestion and qualification workflow with an Intelligence inbox, plus a local dry-run campaign runner for extracting and scoring leads. The runner now isolates fetch and extraction failures so one bad source does not stop the campaign, and it keeps all output local without CRM, Neon, outreach, or email side effects.
New Features
--sync-crm, production, and Neon flags.apps/agent/var/local-leadswith focused tests for extraction, failure handling, staging, validation, scoring, export, and safety checks.Migration
Written for commit 8448b63. Summary will update on new commits.