Add local campaign progress instrumentation and schema compliance - #225
lumenstech wants to merge 52 commits into
Conversation
release: promote main
…es--release chore: release release
release: promote main
release: promote main
release: promote main
release: promote main
release: promote main
release: promote main
release: promote main
release: v1.8.1
release: v1.8.2
release: v1.9.0
release: v1.10.0
release: v1.11.0
release: v1.12.0
release: v1.13.0
release: v1.14.0
release: v1.15.0
release: v1.15.1
release: v1.15.2
release: v1.15.3
|
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 |
There was a problem hiding this comment.
40 issues found across 34 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/lib/local-campaign-staging.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-staging.ts:67">
P1: When a source page contains multiple companies, every candidate shares `source_domain`, so `stageCampaignLead` marks all but the first company from that domain as `duplicate`. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.</violation>
</file>
<file name="apps/agent/agent/lib/local-lead-extraction.ts">
<violation number="1" location="apps/agent/agent/lib/local-lead-extraction.ts:105">
P1: The parser accepts fabricated leads because it never verifies `evidence_excerpt` against the source text. Pass `text` into the parser and reject excerpts absent from the source, as `local-campaign-extraction.ts` does.</violation>
<violation number="2" location="apps/agent/agent/lib/local-lead-extraction.ts:154">
P1: A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.</violation>
<violation number="3" location="apps/agent/agent/lib/local-lead-extraction.ts:164">
P1: A server that sends headers and then stalls the body blocks `fetchLocalLeadSource` and the campaign run. Keep the abort timer active through body consumption and cancel the reader on timeout.</violation>
</file>
<file name="packages/db/prisma/schema.prisma">
<violation number="1" location="packages/db/prisma/schema.prisma:1700">
P1: A canonical opportunity or source record can reference a company or pipeline from another business unit because these foreign keys do not constrain the shared `businessUnitId`. Add composite business-unit-aware foreign keys or validate the association transactionally before writes.</violation>
<violation number="2" location="packages/db/prisma/schema.prisma:1735">
P1: When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by `businessUnitId`, and update the ingest conflict target and lookup to match.</violation>
<violation number="3" location="packages/db/prisma/schema.prisma:1782">
P2: Campaign members and approvals accept target IDs that the database never validates. Add relations and foreign keys to the canonical company, person, and campaign-member records, or add explicit validation for the chosen polymorphic target design.</violation>
<violation number="4" location="packages/db/prisma/schema.prisma:1790">
P2: Because `companyId` and `personId` are nullable, PostgreSQL allows duplicate company-only and person-only members within one campaign. Enforce the two target shapes with partial unique indexes or a non-null target key.</violation>
<violation number="5" location="packages/db/prisma/schema.prisma:1829">
P2: `AgentRunContext` can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an `AgentRun` relation and the matching foreign key with the intended delete behavior.</violation>
</file>
<file name="apps/agent/agent/lib/local-ollama.ts">
<violation number="1" location="apps/agent/agent/lib/local-ollama.ts:62">
P1: When Ollama sends headers but stalls while streaming the JSON body, `generate` clears its timer and waits indefinitely in `response.json()`, so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.</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:82">
P2: When a project has no row in the current limited, status-filtered result, it never appears in the Project select. Load project options independently of the filtered signal rows, or expose project facets from the API.</violation>
<violation number="2" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:220">
P1: After a successful resolve or qualification, the sheet keeps the old `SignalRow`, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.</violation>
<violation number="3" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:237">
P1: When a user reviews another signal without unmounting the sheet, `score` retains the previous signal’s components and Save score submits them to the new signal. Reset score when `row?.sourceRecordId` changes.</violation>
<violation number="4" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:581">
P2: For canonical-only mappings, `row.mapped` is true while `mappedCompanyId` is null, so Create visible deal is enabled and the API rejects it. Gate this button on `mappedCompanyId` as well as the score and owner.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-source-units.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-source-units.ts:71">
P1: Ordinary same-origin links become source units because the normalized `href` itself satisfies the website evidence check. Exclude same-origin or non-company links before calling `buildSourceUnit`, otherwise navigation links consume the per-seed Ollama calls.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-source-units.ts:120">
P2: When a block contains a company's website only in an anchor attribute, `cleanText` removes the `href`, so Ollama never receives the website in `candidate_text`. Preserve valid anchor URLs before stripping HTML tags.</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: When `estimated_value_usd` is negative or exceeds the monetary column limit, `promote` bypasses the input amount validation and writes invalid money or fails with a database error. Validate the resolved amount against the supported nonnegative USD range before promotion.</violation>
<violation number="2" location="apps/api/src/ingest/signal-qualification.service.ts:113">
P1: Concurrent promotions for one source can both observe no opportunity mapping, create different opportunities and deals, and let the last mapping upsert win. Serialize promotion and create or reuse the mapping atomically.</violation>
<violation number="3" location="apps/api/src/ingest/signal-qualification.service.ts:149">
P2: When `createDeal` is true but its score, company link, or owner is invalid, `promote` has already inserted or updated the canonical opportunity before throwing. Validate these prerequisites before the canonical write.</violation>
<violation number="4" location="apps/api/src/ingest/signal-qualification.service.ts:184">
P2: If the final mapping write fails after deal creation, `promote` leaves the canonical opportunity and deal persisted without a source mapping. Run the promotion writes in one shared transaction or add explicit compensation.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-runner.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-runner.ts:396">
P2: The deadline check runs only before each candidate. If the final extraction or staging crosses `perSeedTimeoutMs`, the runner still stages the lead and reports `seed_completed`; enforce the deadline around awaited work and before final completion.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-runner.ts:409">
P2: Schema repair can make `extractLocalCampaignLead` call Ollama twice, but these counters increment once per extraction. Count every `client.generate` attempt so telemetry and both call limits include retries.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-schema.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-schema.ts:29">
P2: When a campaign includes an unknown `required_fields` entry, the runner treats that field as present because `lead[field]` is `undefined`, not `null`. Restrict `required_fields` to supported lead fields so invalid configurations cannot bypass required-field review.</violation>
</file>
<file name="apps/api/src/ingest/ingest.service.ts">
<violation number="1" location="apps/api/src/ingest/ingest.service.ts:117">
P2: Re-ingesting an already qualified signal replaces its payload and erases `qualification` and its saved evidence. Merge the existing payload with the new source payload so source refreshes preserve review state.</violation>
<violation number="2" location="apps/api/src/ingest/ingest.service.ts:140">
P2: When a valid signal contains nonnumeric text in `payload.metadata.fit_score`, `inbox` fails with a PostgreSQL cast error instead of returning the signal. Guard every numeric cast, or normalize numeric fields before storage, before applying `minScore` and ordering.</violation>
<violation number="3" location="apps/api/src/ingest/ingest.service.ts:157">
P2: When callers pass `source`, `inbox` ignores it and returns records from every source system. Add a `sourceSystem = input.source` predicate so the API's declared filter works.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-extraction.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-extraction.ts:89">
P2: When a non-null scalar is absent from `evidence_excerpt`, the parser skips the repair attempt and counts otherwise repairable model output as a failed extraction. Include this validation error in `needsSchemaRepair` so the one repair attempt handles it.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-extraction.ts:155">
P2: When the first response is repairable and a campaign call limit is one, this retry performs a second Ollama request despite the exhausted budget, while progress and result counters report one call. Charge retry attempts against the campaign budget and count or emit each underlying generation.</violation>
</file>
<file name="apps/agent/agent/cli/local-campaign-worker.ts">
<violation number="1" location="apps/agent/agent/cli/local-campaign-worker.ts:34">
P2: CLI overrides bypass campaign bounds and can round positive values to zero. Require integers within each schema limit, then validate the merged campaign before running.</violation>
<violation number="2" location="apps/agent/agent/cli/local-campaign-worker.ts:105">
P2: Export fails on a fresh checkout because it does not create `var/local-leads` before writing. Create the output directory before `writeFile`.</violation>
</file>
<file name="apps/api/src/app.module.ts">
<violation number="1" location="apps/api/src/app.module.ts:79">
P1: When `AppModule` imports `IngestModule`, its router is not attached to the tRPC host because `IngestModule` does not import `TrpcModule`. Add `TrpcModule` to `IngestModule` imports so the ingest tRPC procedures and REST bridge are registered.</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: When a staging file or parent directory is a symlink, this lexical check accepts it and `stageLocalLead` follows the link, so lead data can be read from or appended outside `var/local-leads`. Validate the real path or open the file and its parents with no-follow semantics before reading and appending.</violation>
</file>
<file name="apps/agent/agent/cli/local-lead-worker.ts">
<violation number="1" location="apps/agent/agent/cli/local-lead-worker.ts:51">
P2: When `--source-url` is not a URL, the worker spends an Ollama call before schema validation always fails. Validate and normalize `--source-url` before extraction.</violation>
<violation number="2" location="apps/agent/agent/cli/local-lead-worker.ts:52">
P2: When `--text` carries an oversized payload, this branch sends it directly to Ollama without the 512 KiB guard used for `--url`. Enforce the same byte limit before extraction.</violation>
</file>
<file name="apps/agent/test/local-campaign.spec.ts">
<violation number="1" location="apps/agent/test/local-campaign.spec.ts:66">
P2: This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records `onProgress` for successful and failed seeds and asserts the key lifecycle events and counters.</violation>
<violation number="2" location="apps/agent/test/local-campaign.spec.ts:623">
P2: This import-safety test misses `@crm/db/<subpath>` imports, so a forbidden CRM dependency can be added while the test still passes. Match the package prefix through the closing quote, including subpaths and other import forms.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-lead-schema.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-lead-schema.ts:6">
P2: This validator accepts non-web URI schemes as `website`, so values such as `mailto:` or `javascript:` can be staged as company websites. Restrict the website URL validator to `http` and `https` schemes.</violation>
<violation number="2" location="apps/agent/agent/lib/local-campaign-lead-schema.ts:6">
P2: When a source unit contains a bare `www.example.com` value, this validator rejects the copied website, causing a retry/failure or losing website data that source extraction identified. Normalize bare `www.` values to an HTTP(S) URL before parsing, or accept that source form consistently.</violation>
</file>
<file name="apps/agent/agent/lib/local-campaign-scoring.ts">
<violation number="1" location="apps/agent/agent/lib/local-campaign-scoring.ts:21">
P2: Because `project_fit_reason` is model-generated and not required to occur in `evidence_excerpt` or source text, this line lets Ollama inflate `fit_score` with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.</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: This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add `await` before `expect(...)` to make the test fail when `fetchLocalLeadSource` stops enforcing the limit.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
|
|
||
| function dedupeKeys(lead: LocalStagedLead): string[] { | ||
| return [ | ||
| `domain:${normalizeDomain(lead.source_domain)}`, |
There was a problem hiding this comment.
P1: When a source page contains multiple companies, every candidate shares source_domain, so stageCampaignLead marks all but the first company from that domain as duplicate. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.
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-staging.ts, line 67:
<comment>When a source page contains multiple companies, every candidate shares `source_domain`, so `stageCampaignLead` marks all but the first company from that domain as `duplicate`. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.</comment>
<file context>
@@ -0,0 +1,116 @@
+
+function dedupeKeys(lead: LocalStagedLead): string[] {
+ return [
+ `domain:${normalizeDomain(lead.source_domain)}`,
+ lead.company_name ? `company:${normalizeText(lead.company_name)}` : null,
+ lead.email ? `email:${normalizeText(lead.email)}` : null,
</file context>
| let response: Response; | ||
| try { | ||
| response = await Promise.race([ | ||
| fetchImpl(url, { |
There was a problem hiding this comment.
P1: A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.
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 154:
<comment>A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.</comment>
<file context>
@@ -0,0 +1,301 @@
+ let response: Response;
+ try {
+ response = await Promise.race([
+ fetchImpl(url, {
+ signal: controller.signal,
+ headers: { accept: "text/html,text/plain;q=0.9" },
</file context>
| opportunityId String? | ||
| opportunity CanonicalOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull) | ||
|
|
||
| @@unique([sourceSystem, sourceType, sourceId]) |
There was a problem hiding this comment.
P1: When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by businessUnitId, and update the ingest conflict target and lookup to match.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 1735:
<comment>When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by `businessUnitId`, and update the ingest conflict target and lookup to match.</comment>
<file context>
@@ -1583,3 +1592,249 @@ model Apikey {
+ opportunityId String?
+ opportunity CanonicalOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull)
+
+ @@unique([sourceSystem, sourceType, sourceId])
+ @@index([businessUnitId, sourceType])
+ @@map("source_record")
</file context>
| throw new Error(`Ollama returned HTTP ${response.status}.`); | ||
| } | ||
|
|
||
| const body = (await response.json()) as { response?: unknown }; |
There was a problem hiding this comment.
P1: When Ollama sends headers but stalls while streaming the JSON body, generate clears its timer and waits indefinitely in response.json(), so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.
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-ollama.ts, line 62:
<comment>When Ollama sends headers but stalls while streaming the JSON body, `generate` clears its timer and waits indefinitely in `response.json()`, so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.</comment>
<file context>
@@ -0,0 +1,73 @@
+ throw new Error(`Ollama returned HTTP ${response.status}.`);
+ }
+
+ const body = (await response.json()) as { response?: unknown };
+ if (typeof body.response !== "string") {
+ throw new Error("Ollama returned no text response.");
</file context>
| row={selected} | ||
| onClose={() => setSelected(null)} | ||
| onChanged={async () => { | ||
| await signals.refetch(); |
There was a problem hiding this comment.
P1: After a successful resolve or qualification, the sheet keeps the old SignalRow, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx, line 220:
<comment>After a successful resolve or qualification, the sheet keeps the old `SignalRow`, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.</comment>
<file context>
@@ -0,0 +1,650 @@
+ row={selected}
+ onClose={() => setSelected(null)}
+ onChanged={async () => {
+ await signals.refetch();
+ }}
+ />
</file context>
| lead.company_name, | ||
| lead.website, | ||
| lead.location, | ||
| lead.project_fit_reason, |
There was a problem hiding this comment.
P2: Because project_fit_reason is model-generated and not required to occur in evidence_excerpt or source text, this line lets Ollama inflate fit_score with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.
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-scoring.ts, line 21:
<comment>Because `project_fit_reason` is model-generated and not required to occur in `evidence_excerpt` or source text, this line lets Ollama inflate `fit_score` with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.</comment>
<file context>
@@ -0,0 +1,91 @@
+ lead.company_name,
+ lead.website,
+ lead.location,
+ lead.project_fit_reason,
+ lead.evidence_excerpt,
+ ]
</file context>
| missing_fields: ["contact_name", "role", "email", "phone"], | ||
| }); | ||
|
|
||
| describe("local campaign runner", () => { |
There was a problem hiding this comment.
P2: This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records onProgress for successful and failed seeds and asserts the key lifecycle events and counters.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/local-campaign.spec.ts, line 66:
<comment>This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records `onProgress` for successful and failed seeds and asserts the key lifecycle events and counters.</comment>
<file context>
@@ -0,0 +1,646 @@
+ missing_fields: ["contact_name", "role", "email", "phone"],
+});
+
+describe("local campaign runner", () => {
+ it("validates campaign definitions", () => {
+ expect(localCampaignSchema.safeParse(campaign).success).toBe(true);
</file context>
| expect( | ||
| fetchLocalLeadSource("https://example.com", async () => response, { | ||
| maxBytes: 4, | ||
| lookup: async () => [{ address: "93.184.216.34", family: 4 }], | ||
| }), | ||
| ).rejects.toThrow("maximum content size"); |
There was a problem hiding this comment.
P2: This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add await before expect(...) to make the test fail when fetchLocalLeadSource stops enforcing the limit.
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-extraction.spec.ts, line 85:
<comment>This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add `await` before `expect(...)` to make the test fail when `fetchLocalLeadSource` stops enforcing the limit.</comment>
<file context>
@@ -0,0 +1,162 @@
+ status: 200,
+ headers: { "content-length": "20" },
+ });
+ expect(
+ fetchLocalLeadSource("https://example.com", async () => response, {
+ maxBytes: 4,
</file context>
| expect( | |
| fetchLocalLeadSource("https://example.com", async () => response, { | |
| maxBytes: 4, | |
| lookup: async () => [{ address: "93.184.216.34", family: 4 }], | |
| }), | |
| ).rejects.toThrow("maximum content size"); | |
| await expect( | |
| fetchLocalLeadSource("https://example.com", async () => response, { | |
| maxBytes: 4, | |
| lookup: async () => [{ address: "93.184.216.34", family: 4 }], | |
| }), | |
| ).rejects.toThrow("maximum content size"); |
| id String @id @default(uuid()) | ||
| businessUnitId String | ||
| businessUnit BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict) | ||
| agentRunId String |
There was a problem hiding this comment.
P2: AgentRunContext can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an AgentRun relation and the matching foreign key with the intended delete behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 1829:
<comment>`AgentRunContext` can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an `AgentRun` relation and the matching foreign key with the intended delete behavior.</comment>
<file context>
@@ -1583,3 +1592,249 @@ model Apikey {
+ id String @id @default(uuid())
+ businessUnitId String
+ businessUnit BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict)
+ agentRunId String
+ budget Int @default(0)
+ fitScores Json?
</file context>
| if (!output) throw new Error("Export needs --out."); | ||
| const safeOutput = assertLocalCampaignPath(output); | ||
| if (format === "jsonl") { | ||
| await writeFile(safeOutput, localCampaignJsonl(leads), "utf8"); |
There was a problem hiding this comment.
P2: Export fails on a fresh checkout because it does not create var/local-leads before writing. Create the output directory before writeFile.
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 105:
<comment>Export fails on a fresh checkout because it does not create `var/local-leads` before writing. Create the output directory before `writeFile`.</comment>
<file context>
@@ -0,0 +1,126 @@
+ if (!output) throw new Error("Export needs --out.");
+ const safeOutput = assertLocalCampaignPath(output);
+ if (format === "jsonl") {
+ await writeFile(safeOutput, localCampaignJsonl(leads), "utf8");
+ } else if (format === "csv") {
+ await writeFile(safeOutput, localCampaignCsv(leads), "utf8");
</file context>
Summary
Pilot Result
Safety
Tests
Known Follow-ups
Summary by cubic
Adds a local-only campaign runner that turns seed pages into validated, scored, staged leads, plus an authenticated Intelligence inbox for reviewing commercial signals before CRM promotion. Local extraction now rejects malformed model output and unusable sources while remaining dry-run safe.
Local campaign workflow
apps/agent/var/local-leadswithout CRM, Neon, or outreach writes.Signal workflow and migration
Written for commit 1e53e61. Summary will update on new commits.