Skip to content

Add local lead campaign runner - #224

Open
lumenstech wants to merge 28 commits into
trycompai:releasefrom
lumenstech:local-lead-campaign-runner
Open

lumenstech wants to merge 28 commits into
trycompai:releasefrom
lumenstech:local-lead-campaign-runner

Conversation

@lumenstech

@lumenstech lumenstech commented Sep 16, 2026

Copy link
Copy Markdown

Summary

  • Adds a local dry-run campaign runner for lead mining.
  • Supports campaign JSON validation, seed URL/CSV/JSONL ingestion, safe fetching, caching, Ollama extraction, scoring, dedupe, staging, and export.
  • Adds a sample PartWall 2027 exhibitor campaign.

Safety

  • Local-only staging.
  • No CRM sync.
  • No Neon writes.
  • No migrations.
  • No outreach or email sending.
  • --sync-crm is rejected before work begins.
  • No CRM, Prisma, Neon, database, outreach, or email imports in the local campaign path.

Tests

  • Focused tests: 25 passed.
  • Agent type check: passed.
  • Biome: passed.
  • git diff --check: passed.
  • Campaign validation: passed.
  • CRM sync refusal: passed.
  • Forbidden import scan: passed.

Run

bun run --filter=agent local-campaign:dry-run -- run \
  --campaign apps/agent/campaigns/partwall-2027.json \
  --seed-file /path/to/partwall-seeds.jsonl

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

  • Adds idempotent APIs for ingesting signals, viewing inbox records, resolving companies, qualifying signals, and promoting accepted signals.
  • Adds filtering and review actions before signals become CRM records or opportunities.
  • Supports campaign validation, URL/CSV/JSONL seeds, safe fetching and caching, local Ollama extraction, scoring, deduplication, staging, and JSONL/CSV export.
  • Includes a PartWall 2027 campaign config and rejects --sync-crm, production, and Neon flags.
  • Stores campaign output under apps/agent/var/local-leads with focused tests for extraction, failure handling, staging, validation, scoring, export, and safety checks.

Migration

  • Apply the new Prisma migration before using the signal workflow; it adds business units, canonical companies, signals, source records, mappings, qualification state, and nullable business-unit links for existing records.

Written for commit 8448b63. Summary will update on new commits.

Review in cubic

lumenstech and others added 27 commits September 3, 2026 21:09
@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

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.

@github-actions
github-actions Bot changed the base branch from release to main September 16, 2026 20:18
@github-actions

Copy link
Copy Markdown
Contributor

Retargeted this onto main.

release is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to main, and reach release when a release is cut.

Nothing is wrong with your branch. If the diff now shows commits that are already on main, rebase and force-push:

git fetch origin main
git rebase origin/main
git push --force-with-lease

@comp-ai-eve-factory

Copy link
Copy Markdown

Approve tool call: github__addPullRequestComment

  1. Approve
  2. Cancel

Answer by mentioning me in a reply, e.g. @comp-ai-eve-factory Approve.

@lumenstech
lumenstech changed the base branch from main to release September 16, 2026 20:25

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

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");

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

options.lookup ?? resolveHostname,
);

const response = await fetchImpl(url, {

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

if (isBlockedHostname(parsed.hostname)) {
throw new Error("Local source URLs are blocked.");
}
await assertPublicHostname(

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

text,
].join("\n");
const result = await client.generate({ model: LOCAL_CAMPAIGN_MODEL, prompt });
const lead = parseLocalCampaignResponse(result.text, sourceUrl);

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

"campaign_id": "partwall-trade-show-2027",
"project": "PartWall",
"target_customer_type": "trade show exhibitors and industrial display buyers",
"geography": ["United States", "Canada"],

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic


function valueAfter(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : undefined;

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

throw new Error("Local source URLs are blocked.");
}
await assertPublicHostname(
parsed.hostname,

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
parsed.hostname,
parsed.hostname.replace(/^\[|\]$/g, ""),
Fix with cubic

this.text(payload.subject) ??
this.text(payload.message) ??
"Qualified opportunity";
const amount = input.amountUsd ?? this.number(payload.estimated_value_usd);

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

"https://example.com/acme",
{ generate: async () => ({ text: response, latencyMs: 5 }) },
);
expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
await expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
Fix with cubic

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant