Agentic CRM wave: lifecycle specialists, hygiene, mailbox, observability - #140
Agentic CRM wave: lifecycle specialists, hygiene, mailbox, observability#140aiandwebservices-cyber 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
Report AGENT_BRIDGE_SECRET with LinkedIn, Perplexity, Blob, and Context. Missing keys stay soft-fail. Export FULL_AGENTIC_CHECKLIST (env names only) for production enable, align docs/.env.example, and note names on Settings. Full agentic mode checklist: - RAPIDAPI_KEY - PERPLEXITY_API_KEY - BLOB_READ_WRITE_TOKEN - AGENT_BRIDGE_SECRET (same value on app + agent) - Context key at Settings → General (not an env var)
Brand tasks already run on DIRECT_KINDS at priority 900, but a missing Context key wrote SKIPPED and stranded companies outside the re-queue sweep. Return without changing enrichment status when Context is off so the company stays PENDING. Pin lane priority in hermetic tests and align docs/agent.md two-lane rules with DIRECT_KINDS.
Add integration coverage for iCalUID dedupe, relevance filtering, activity projection, cancellation, and 410 cursor reset. Document remaining Gmail and auto-create handoff in the plan.
Resolve LinkedIn candidates via Context site:linkedin.com/in search instead of Perplexity. LinkDAPI stays enricher-only. Return code-priced evidence and refuse partial matches so a miss stays a miss.
Export PORTRAIT_STAND_DOWN_MS next to priority 800, skip photo backfill when BLOB_READ_WRITE_TOKEN is missing, and pin source-chain and blob soft-fail tests so faces only come from person-tied sources.
Open deals with no activity for 14 days enqueue a stalled-deal AgentTask. The direct lane raises one owner Activity task and never calls the model.
Raise superseding employer facts as a timeline note plus owner TASK. Gate company re-parent on human approval; allow unattended raise without moveToCompanyId. Add recheck cadence config, job-change skill, and tests.
Wire the calendar-enqueued meeting-prep path with a skill and code gate so write_brief only lands when identity is trustworthy.
Add Gmail sync acceptance coverage for history first-sight, company timeline projection, cross-mailbox rfcMessageId dedupe, body privacy on list vs expand, relevance when auto-create is off, idempotent re-runs, and history 404 cursor reset. Document phase 3 status and phase 4 handoff in the gmail-calendar plan.
Add lifecycleRole to team-agent manifests and a recommend-only Qualify template (NOTE/TASK + run.summary). Surface role on agent list/detail. Saving stays READY; Deploy remains the human gate. No send tools.
Add lifecycleRole to team-agent manifests and a recommend-only Qualify template (NOTE/TASK + run.summary). Surface role on agent list/detail. Saving stays READY; Deploy remains the human gate. No send tools.
Add lifecycle-close template with lifecycleRole close: SELECTED default, deal.closed workspace option, NOTE/TASK + run.summary only. No send tools, no reopen, no finance writes. Mirror qualify Deploy gate.
Add lifecycleRole=engage template that recommends next outreach and queues CRM notes/tasks only. No send email/SMS tools. Deploy remains the human gate. Mirrors Qualify specialist pattern.
Add lifecycleRole substrate and a recommend-only Advance template (next stage/step as NOTE/TASK + run.summary). Workspace triggers cover deal.opened, deal.stage.changed, daily stall schedule, and manual. No stage mutation, no send tools. Deploy remains the human gate.
Phase 4 of the gmail/calendar plan: acceptance coverage for newsletter rejection, rep-sent EMAIL create, calendar CALENDAR create, free/no-reply/ suppressed domain rejection, and autoCreate off. Document phase 4 status.
Surface fleet health on agents.observability and the team index, extend install_daily with team_runs/actions/token/cost aggregates, and show per-run cost, tokens, and trace steps. lifecycleRole is parsed for role-aware counts; free text stays off the wire.
Add Deal score/forecast columns, research-lane deal-score tasks, Nest enqueue on stage change and nightly sweep, agent write tool, and sheet UI. forecastContextManual wins when set. Intelligence stays in apps/agent.
|
@aiandwebservices-cyber 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 102 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/identity-verdict.ts">
<violation number="1" location="apps/agent/agent/lib/identity-verdict.ts:21">
P1: A colleague with the same employer and a first-name-only address (for example, `chris@northwind.com` for a `Chris Smith` profile) is treated as the same person and can have their name/photo applied to the CRM contact. Identity matching should require a surname-bearing local-part form (full surname plus first name/initial) before returning `isSamePerson` or VERIFIED evidence; the identity guide explicitly says a matching first name is not identity evidence.</violation>
</file>
<file name="apps/agent/agent/lib/linkedin-candidates.ts">
<violation number="1" location="apps/agent/agent/lib/linkedin-candidates.ts:16">
P1: A malformed percent escape in any search result can crash `resolve_linkedin_profile` instead of returning candidates; ignoring malformed profile references keeps external result content from taking down the identity workflow.</violation>
<violation number="2" location="apps/agent/agent/lib/linkedin-candidates.ts:71">
P2: A later Context search failure discards candidates found by earlier successful terms, causing a partial provider outage to look like a finished LinkedIn miss; retaining the collected slugs while marking the response incomplete preserves candidates that still can be verified.</violation>
</file>
<file name="apps/agent/agent/lib/lifecycle-advance.ts">
<violation number="1" location="apps/agent/agent/lib/lifecycle-advance.ts:43">
P2: Newly created open deals will not receive the advertised first next-step recommendation: this trigger listens for reopening a closed deal, while deal creation emits `deal.created`. Using the creation event here (with the existing stage-change trigger for later transitions) covers the intended new-deal workflow.</violation>
<violation number="2" location="apps/agent/agent/lib/lifecycle-advance.ts:128">
P1: The daily stall-check schedule cannot be saved because its first run is set to now rather than a future time. Computing `nextRunAt` as `now + ADVANCE_STALL_INTERVAL_MINUTES` lets builder validation persist the trigger and prevents the schedule from being dropped.</violation>
</file>
<file name="apps/agent/agent/lib/brief-identity.ts">
<violation number="1" location="apps/agent/agent/lib/brief-identity.ts:31">
P1: A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.</violation>
<violation number="2" location="apps/agent/agent/lib/brief-identity.ts:34">
P2: In `contactIdentityIsTrustworthy`, the last-name branch intends to reject a name still derived from the email handle, but `isDerivedName` returns false whenever `lastName` is non-null — exactly the branch this call lives in — so `!isDerivedName(...)` is always true and only `contact.lastName` is actually being tested. Any contact with a stored last name, including one split from the email local part (e.g. `paula.marchetti@…` → Paula/Marchetti via `splitName`), is treated as trustworthy with no LinkedIn and no applied-name fact, weakening the meeting-prep identity gate. Consider making `isDerivedName` detect full names derived from both local-part tokens, or gate on the placeholder case explicitly rather than on the mere presence of a last name.</violation>
</file>
<file name="apps/agent/agent/tools/resolve_linkedin_profile.ts">
<violation number="1" location="apps/agent/agent/tools/resolve_linkedin_profile.ts:16">
P3: When Context.dev is unavailable, the tool reports a missing `CONTEXT_DEV` capability even though its key is configured in Settings → General, not as an environment variable. A setting-specific label would point operators to the configuration this code actually reads.</violation>
<violation number="2" location="apps/agent/agent/tools/resolve_linkedin_profile.ts:22">
P2: This resolver can make several Context vendor calls while consuming only one research-budget unit: `findLinkedInCandidates` searches once for each derived email term. Charging each search (or passing budget accounting into the helper) would prevent later research from continuing after the session budget is exhausted.</violation>
</file>
<file name="apps/agent/agent/lib/stalled-deal.ts">
<violation number="1" location="apps/agent/agent/lib/stalled-deal.ts:41">
P2: A deal that becomes active after the sweep can still receive a stalled-deal owner task because this worker never verifies that the freshly read activity timestamp is still at least `STALLED_DEAL.inactiveDays` old. Rechecking the threshold before creating the activity would prevent delayed queue work from generating an obsolete task.</violation>
</file>
<file name="apps/agent/agent/lib/lifecycle-close.ts">
<violation number="1" location="apps/agent/agent/lib/lifecycle-close.ts:97">
P2: A selected Close run can be created for a company or contact with no deal, allowing win/loss and closed-deal recommendations to be written against a non-deal record. Require at least one `deal` resource for `SELECTED` scope while still allowing linked records if needed.</violation>
</file>
<file name="apps/agent/agent/lib/job-change.ts">
<violation number="1" location="apps/agent/agent/lib/job-change.ts:70">
P2: Retries or a second `record_job_change` call for the same employer transition create another timeline note and another open owner task. The transition remains detectable after the first raise, so rechecks and retries can spam the owner; deduplicate by the `job-change` metadata, including the `from`/`to` pair, before writing.</violation>
<violation number="2" location="apps/agent/agent/lib/job-change.ts:82">
P2: An invalid or deleted approved `moveToCompanyId` makes the re-parent update fail after the note and task have already been persisted. Validating the company before writing activities and keeping the activity/re-parent operation atomic would prevent a failed run from leaving signals that a retry duplicates.</violation>
</file>
<file name="apps/app/components/crm/record-sheet/deal-sheet.tsx">
<violation number="1" location="apps/app/components/crm/record-sheet/deal-sheet.tsx:434">
P2: A rep's manual forecast override is not shown as the forecast: the sheet continues to display the superseded AI context under `From the agent`, or says no forecast exists when only the manual value is set. Render `forecastContextManual` as the displayed context and fall back to `forecastContext` only when the manual value is blank.</violation>
</file>
<file name="apps/api/src/deals/stalled-deals.service.ts">
<violation number="1" location="apps/api/src/deals/stalled-deals.service.ts:115">
P2: Stale deals are re-enqueued on every later sign-in sweep after the first owner task is created, even while that owner task remains open. Deduplication should also recognize the open stalled-deal activity or apply a persisted cooldown so the direct lane does not repeatedly process the same deal.</violation>
<violation number="2" location="apps/api/src/deals/stalled-deals.service.ts:115">
P2: Re-nudging loop: because this lane is recommend-only and nothing here updates a deal's `lastActivityAt` when a re-engage task is produced, a permanently stale open deal is re-enqueued on every sweep after its previous task completes. `enqueue` only deduplicates against open (`finishedAt: null`) tasks, so once a stalled-deal task finishes, the same deal is considered stalled again and a brand-new `stalled-deal` agent task is created on the next sign-in-triggered sweep — repeatedly consuming agent budget and creating recurring Re-engage tasks for a deal that never changes. Consider adding a watermark (e.g. only re-queue a deal if no completed `stalled-deal` task exists after the cutoff, or touch `lastActivityAt` when the re-engage task is created) so a stuck deal is not re-nudged indefinitely.</violation>
</file>
<file name="apps/api/src/telemetry/rollup.service.ts">
<violation number="1" location="apps/api/src/telemetry/rollup.service.ts:248">
P3: Team-agent aggregation is now implemented a second time in `RollupService`, duplicating `AgentObservabilityService.fleet` and allowing the in-product and telemetry metrics to drift as the model evolves. A shared aggregation helper would preserve one definition of these counters while leaving allowlist shaping at the telemetry boundary.</violation>
<violation number="2" location="apps/api/src/telemetry/rollup.service.ts:308">
P3: Unknown lifecycle roles are bucketed as none, not other, and the other bucket is unreachable. readLifecycleRole nullifies any non-qualify/engage/advance/close role before permittedLifecycleRole runs, so the OTHER branch never triggers and invalid values land in none. This contradicts docs/telemetry.md, which says unknown values are counted as other. To match the docs, distinguish an untagged manifest (lifecycleRole absent) from a present-but-invalid value (lifecycleRole present but not in the enum) and route the invalid case to other.</violation>
<violation number="3" location="apps/api/src/telemetry/rollup.service.ts:313">
P2: `team_runs_cancel_after_action` reports a cancelled run with only planned, running, failed, or cancelled actions as having completed an action. Counting only completed/succeeded `AgentAction` rows is needed so this telemetry does not overstate side effects that actually occurred.</violation>
</file>
<file name="apps/agent/agent/lib/capabilities.ts">
<violation number="1" location="apps/agent/agent/lib/capabilities.ts:23">
P2: FULL_AGENTIC_CHECKLIST re-declares (id + label + source) the same four env capabilities that capabilitiesFrom() constructs inline, and docs/environment.md explicitly calls this list the 'source of truth'. The runtime capability set actually lives in capabilitiesFrom(), so the two lists can silently drift: adding a new env capability there without also updating the checklist leaves the enable checklist stale even though `FULL_AGENTIC_ENV_VARS` is asserted to the exact four known vars in the test. Worth deriving the env portion from a single list, or adding a test that fails when capabilitiesFrom() and FULL_AGENTIC_CHECKLIST disagree, so a future capability can't be missed.</violation>
<violation number="2" location="apps/agent/agent/lib/capabilities.ts:193">
P2: The no-argument checklist always shows the stored Context key as missing, so a configured install can be told it is not fully enabled. Requiring resolved capabilities here (or making the helper async and loading `capabilities()`) would prevent a false checklist result.</violation>
</file>
<file name="apps/agent/agent/lib/deal-intelligence.ts">
<violation number="1" location="apps/agent/agent/lib/deal-intelligence.ts:30">
P2: Invalid scores are silently rewritten before validation, so a malformed score can become a persisted 0, 100, or rounded value and produce misleading deal intelligence. Validate `input.score` before clamping (or remove clamping and reject invalid values).</violation>
</file>
<file name="apps/api/src/agent/agent-trigger.service.ts">
<violation number="1" location="apps/api/src/agent/agent-trigger.service.ts:402">
P2: Rolling API instances no longer share the idempotency lock for existing non-deal tasks because the new empty `dealId` segment changes the key format even when `task.dealId` is unset. Concurrent old/new enqueue calls can both pass `findFirst` and create duplicate agent tasks; preserve the old key for non-deal tasks and append `dealId` only when present.</violation>
</file>
<file name="apps/agent/agent/tools/write_deal_intelligence.ts">
<violation number="1" location="apps/agent/agent/tools/write_deal_intelligence.ts:20">
P3: The writer can persist a one-character rationale or forecast context despite the declared 40-character minimum: whitespace padding passes `.min(40)`, then `blankToNull` trims it before writing. Trimming before applying `.min(40)` for both fields would prevent these low-quality values.</violation>
</file>
<file name="apps/api/src/deals/deals.service.ts">
<violation number="1" location="apps/api/src/deals/deals.service.ts:493">
P2: Closing a deal now always queues a deal-score task because dealScore is enqueued unconditionally on every stage change, even when the new stage is CLOSED_WON/CLOSED_LOST. That contradicts the nightly sweep, which limits itself to OPEN_DEAL_STAGES, and writeDealIntelligence has no stage guard, so the agent writes score/forecast onto a closed record. Consider gating the enqueue to non-closed transitions (e.g. `if (!isClosedStage(input.stage))`) so closed deals are not scored.</violation>
</file>
<file name="docs/telemetry.md">
<violation number="1" location="docs/telemetry.md:133">
P3: Doc says unknown lifecycleRole values report as `other`, but the implementation folds them into `none`. At the finding it is establishable that the code never emits `other` for the role key; the wording should match the actual sentiment: unknown values are `none`.</violation>
</file>
<file name="docs/environment.md">
<violation number="1" location="docs/environment.md:134">
P3: Checklist row for PERPLEXITY_API_KEY says it unlocks 'LinkedIn slug search', which contradicts the main table changed in the same diff ('Open-web research with citations (not identity slug search)') and the code: research_person.ts's tool description states Perplexity is 'NOT a source of truth for someone's identity', and identity-matching.md.md says 'Perplexity is not the...' source for identity. LinkedIn identity comes from RAPIDAPI_KEY/LinkDAPI. Remove the slug-search claim so the checklist matches the capability it actually unlocks and the source-of-truth checklist (capabilities.ts labels it just 'Web research').</violation>
</file>
<file name="apps/api/src/deals/deal-score.service.ts">
<violation number="1" location="apps/api/src/deals/deal-score.service.ts:42">
P2: The `auto()` sweep writes the 24h cache key before the background sweep runs and never clears it on error, so a transient failure in `sweep()` (caught only for logging) leaves the instance locked out of the daily deal-score pass for the rest of the TTL. Consider clearing the key in the catch path (or only setting it after a successful sweep) so a failure doesn't skip an entire day of rescoring.</violation>
<violation number="2" location="apps/api/src/deals/deal-score.service.ts:107">
P3: Deal-score sweep metrics hide enqueue failures by labeling every `agent.dealScore()` false result as `alreadyQueued`; distinguish an existing pending task from a queue error so operators can detect deals that were not scheduled.</violation>
</file>
<file name="apps/api/test/stalled-deals.spec.ts">
<violation number="1" location="apps/api/test/stalled-deals.spec.ts:119">
P2: The sweep's exact-count assertions (queued===1, alreadyQueued===1, scanned) are not scoped to this test's fixtures: StalledDealsService.sweep() scans the entire deals table (stage in OPEN_DEAL_STAGES, lastActivityAt <= cutoff, take 100), not this deal set. Any other open deal left by parallel/prior api test files (deal-score, calendar-sync, gmail, etc. share the same DATABASE_URL) with lastActivityAt before 2026-07-29 will inflate queued/alreadyQueued and break these assertions. Scope the assertions to the fixture dealId set (query agentTask by dealId) instead of asserting global counts.</violation>
</file>
<file name="apps/api/test/agent-observability.spec.ts">
<violation number="1" location="apps/api/test/agent-observability.spec.ts:157">
P2: Test is not hermetic: `fleet()` aggregates over the entire shared workspace within a 24h window (no agent scoping), but beforeAll seeds into the default WORKSPACE_ID alongside parallel agent spec files (agent-runs, agent-delete, agent-events) that also create runs/actions in the same DB+window. Those counts bleed into these assertions; the `>=` guards keep it passing but let the test be satisfied by unrelated data, so it doesn't reliably validate the seeded rows and the metrics are non-deterministic across concurrent runs. Consider scoping assertions to this agent's data or running against an isolated workspace, or filtering the service by agentId to make the test deterministic.</violation>
</file>
<file name="apps/agent/agent/lib/lifecycle-qualify.ts">
<violation number="1" location="apps/agent/agent/lib/lifecycle-qualify.ts:74">
P3: The four lifecycle specialist files duplicate the same recommend-only enforcement and manifest-building logic (identical `X_RECOMMEND_ONLY_ACTION_TYPES`, `isXRecommendOnlyActionType`, and `assertXRecommendOnlyActions` bodies that differ only by role name in the error message, plus the repeated trigger-config/dataScope mapping). A single shared, role-parameterized helper would avoid the duplication and keep the recommend-only rule in one place. Additionally the same `LIFECYCLE_ROLES` list is redefined in `apps/agent/agent/lib/lifecycle-roles.ts` and `packages/validation/src/agents.ts`, allowing the two to drift out of sync.</violation>
<violation number="2" location="apps/agent/agent/lib/lifecycle-qualify.ts:124">
P3: Qualify drafts under-report connected-source access when Gmail or Calendar resources are supplied: the review card/README shows only CRM access even though the run can read those sources from `dataScope.resources`. Deriving the integration access entries from `resources` would keep the human-approved scope accurate.</violation>
</file>
<file name="apps/api/test/deal-score.spec.ts">
<violation number="1" location="apps/api/test/deal-score.spec.ts:131">
P2: Calling DealScoreService.sweep() here scans the ENTIRE deal table in the shared test database, not just this test's fixtures. Any other spec that created an open-stage deal with a null/stale dealScoredAt (e.g. stalled-deals.spec.ts creates QUALIFIED_TO_BUY/DEMO_BOOKED deals with no score) gets a `deal-score` agentTask queued for it, polluting the shared DB and making these assertions depend on arbitrary unrelated rows. Restrict the sweep query to a scoped set of deal ids (or a tenant+createdAt window) so the test only ever enqueues its own deals.</violation>
</file>
<file name="apps/agent/agent/skills/evidence.md">
<violation number="1" location="apps/agent/agent/skills/evidence.md:62">
P3: The write-path description conflates two cases the code treats differently. `recordFact` rejects a human-filled field outright (`humanOwns()` early-returns `stored:false` before any proposal logic), so a human-typed value never becomes a proposal and is never replaced even by VERIFIED evidence; only a prior *agent*-written value (hasAgentFact) with non-VERIFIED band becomes a proposal. As written, the skill tells the agent a human-filled field yields a proposal a rep can accept, which the tool will never return, and 'Only VERIFIED replaces a filled field' contradicts the human case.</violation>
</file>
<file name="apps/agent/test/job-change.integration.spec.ts">
<violation number="1" location="apps/agent/test/job-change.integration.spec.ts:169">
P3: The re-parent `it` block depends on the prior "without moving" `it` having run first, and sequentially re-invokes raiseJobChange to create a second note/task pair for the same change. Reordering or running the re-parent test alone under a filtered/`.only` run makes the "companyId stays companyId" assertion in the earlier block fail, and the coupled rerun leaves duplicate notes/tasks rather than verifying idempotency. Consider isolating state per test (fresh contacts) or explicitly resetting the company + activity between blocks so each test is self-contained.</violation>
</file>
<file name="apps/api/test/mailbox-auto-create.spec.ts">
<violation number="1" location="apps/api/test/mailbox-auto-create.spec.ts:439">
P3: The no-reply test asserts db.company.count({domain: workDomain})===1, which is not verifying no-reply behavior at all — it only passes because an earlier test in a different describe created that company and never cleaned it up. This couples the test to cross-describe execution order and to prior state; it will break if ordering changes or if the outbound test starts cleaning its rows.</violation>
</file>
<file name="apps/agent/test/stalled-deal.integration.spec.ts">
<violation number="1" location="apps/agent/test/stalled-deal.integration.spec.ts:11">
P3: The fixture hardcodes staleAt (2026-07-20) and a fixed reason claiming "23 days" of inactivity, but flagStalledDeal computes meta.daysInactive from the live wall clock, so the stored days keep growing and already disagree with the "23 days" string, making any future assertion on days date-dependent. Derive the date from a fixed offset (e.g. Date.now() - 23*86_400_000) so the fixture stays self-consistent.</violation>
</file>
<file name="apps/api/src/agent/agent-observability.service.ts">
<violation number="1" location="apps/api/src/agent/agent-observability.service.ts:50">
P3: The run/action queries are not scoped to team agents while the agents query is. `agentDefinition.findMany` restricts to `TEAM_AGENT_STATUSES` (LIVE/PAUSED/ARCHIVED), but the `agentRun` and `agentAction` groupBy/findMany queries aggregate over all runs and actions in the workspace regardless of the originating agent's status. As a result a non-team agent (DRAFT, and especially DELETED) can inflate the Runs/Open/Cancelled/token/cost stats while being excluded from the Agents total, making the fleet view internally inconsistent. Consider adding a relation filter on the agent status (e.g. `agent: { status: { in: [...TEAM_AGENT_STATUSES] } }`) to the run and action queries so the whole report covers the same agent set.</violation>
<violation number="2" location="apps/api/src/agent/agent-observability.service.ts:64">
P3: The run-side aggregations (runsByStatus, runsByTrigger, runsByLifecycleRole, quality, consumption) are windowed on run.createdAt, but the action-side aggregations (actionsByType, actionsByStatus) are windowed on agentAction.plannedAt. Because a run and its actions carry different timestamps (actions get their own plannedAt at creation), the two halves of this report can cover slightly different 24h spans — e.g. actions planned right before the window are dropped while the run that produced them is counted, and vice-versa. This makes the Run and Action totals not describe exactly the same period, which muddies the 'fleet health (24h)' readout. Consider aligning both on the same timestamp (e.g. run created/started time via the run relation) so the aggregate is internally consistent.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| const employerMatches = profile.positions.some((position) => | ||
| looksLikeSameCompany(position.name, companyName, companyDomain), | ||
| ); | ||
| const nameMatches = nameMatchesLocalPart(profile, local); |
There was a problem hiding this comment.
P1: A colleague with the same employer and a first-name-only address (for example, chris@northwind.com for a Chris Smith profile) is treated as the same person and can have their name/photo applied to the CRM contact. Identity matching should require a surname-bearing local-part form (full surname plus first name/initial) before returning isSamePerson or VERIFIED evidence; the identity guide explicitly says a matching first name is not identity evidence.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/identity-verdict.ts, line 21:
<comment>A colleague with the same employer and a first-name-only address (for example, `chris@northwind.com` for a `Chris Smith` profile) is treated as the same person and can have their name/photo applied to the CRM contact. Identity matching should require a surname-bearing local-part form (full surname plus first name/initial) before returning `isSamePerson` or VERIFIED evidence; the identity guide explicitly says a matching first name is not identity evidence.</comment>
<file context>
@@ -0,0 +1,76 @@
+ const employerMatches = profile.positions.some((position) =>
+ looksLikeSameCompany(position.name, companyName, companyDomain),
+ );
+ const nameMatches = nameMatchesLocalPart(profile, local);
+
+ return {
</file context>
| const raw = match[1]; | ||
| if (!raw) continue; | ||
|
|
||
| const slug = decodeURIComponent(raw).replace(/\/+$/, "").toLowerCase(); |
There was a problem hiding this comment.
P1: A malformed percent escape in any search result can crash resolve_linkedin_profile instead of returning candidates; ignoring malformed profile references keeps external result content from taking down the identity workflow.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/linkedin-candidates.ts, line 16:
<comment>A malformed percent escape in any search result can crash `resolve_linkedin_profile` instead of returning candidates; ignoring malformed profile references keeps external result content from taking down the identity workflow.</comment>
<file context>
@@ -0,0 +1,91 @@
+ const raw = match[1];
+ if (!raw) continue;
+
+ const slug = decodeURIComponent(raw).replace(/\/+$/, "").toLowerCase();
+ if (slug && !slugs.includes(slug)) slugs.push(slug);
+ }
</file context>
| trigger.type === "SCHEDULE" | ||
| ? { | ||
| ...trigger, | ||
| nextRunAt: options.now ?? new Date().toISOString(), |
There was a problem hiding this comment.
P1: The daily stall-check schedule cannot be saved because its first run is set to now rather than a future time. Computing nextRunAt as now + ADVANCE_STALL_INTERVAL_MINUTES lets builder validation persist the trigger and prevents the schedule from being dropped.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/lifecycle-advance.ts, line 128:
<comment>The daily stall-check schedule cannot be saved because its first run is set to now rather than a future time. Computing `nextRunAt` as `now + ADVANCE_STALL_INTERVAL_MINUTES` lets builder validation persist the trigger and prevents the schedule from being dropped.</comment>
<file context>
@@ -0,0 +1,192 @@
+ trigger.type === "SCHEDULE"
+ ? {
+ ...trigger,
+ nextRunAt: options.now ?? new Date().toISOString(),
+ }
+ : trigger,
</file context>
| contact: ContactIdentitySnapshot, | ||
| ): boolean { | ||
| if (contact.linkedinUrl) return true; | ||
| if (contact.hasAppliedName) return true; |
There was a problem hiding this comment.
P1: A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/brief-identity.ts, line 31:
<comment>A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.</comment>
<file context>
@@ -0,0 +1,60 @@
+ contact: ContactIdentitySnapshot,
+): boolean {
+ if (contact.linkedinUrl) return true;
+ if (contact.hasAppliedName) return true;
+ if (
+ contact.lastName &&
</file context>
| type: "EVENT", | ||
| name: "When a deal is opened", | ||
| summary: "Recommend the first next step for a new open deal", | ||
| event: "deal.opened", |
There was a problem hiding this comment.
P2: Newly created open deals will not receive the advertised first next-step recommendation: this trigger listens for reopening a closed deal, while deal creation emits deal.created. Using the creation event here (with the existing stage-change trigger for later transitions) covers the intended new-deal workflow.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/lifecycle-advance.ts, line 43:
<comment>Newly created open deals will not receive the advertised first next-step recommendation: this trigger listens for reopening a closed deal, while deal creation emits `deal.created`. Using the creation event here (with the existing stage-change trigger for later transitions) covers the intended new-deal workflow.</comment>
<file context>
@@ -0,0 +1,192 @@
+ type: "EVENT",
+ name: "When a deal is opened",
+ summary: "Recommend the first next step for a new open deal",
+ event: "deal.opened",
+ },
+ {
</file context>
|
|
||
| expect(result.external).toEqual([]); | ||
| expect(result.companyId).toBeNull(); | ||
| expect(await db.company.count({ where: { domain: workDomain } })).toBe(1); |
There was a problem hiding this comment.
P3: The no-reply test asserts db.company.count({domain: workDomain})===1, which is not verifying no-reply behavior at all — it only passes because an earlier test in a different describe created that company and never cleaned it up. This couples the test to cross-describe execution order and to prior state; it will break if ordering changes or if the outbound test starts cleaning its rows.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/test/mailbox-auto-create.spec.ts, line 439:
<comment>The no-reply test asserts db.company.count({domain: workDomain})===1, which is not verifying no-reply behavior at all — it only passes because an earlier test in a different describe created that company and never cleaned it up. This couples the test to cross-describe execution order and to prior state; it will break if ordering changes or if the outbound test starts cleaning its rows.</comment>
<file context>
@@ -0,0 +1,576 @@
+
+ expect(result.external).toEqual([]);
+ expect(result.companyId).toBeNull();
+ expect(await db.company.count({ where: { domain: workDomain } })).toBe(1);
+ });
+
</file context>
| const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); | ||
| const domain = `flag-stalled-${suffix}.test`; | ||
| const ownerId = `flag-owner-${suffix}`; | ||
| const staleAt = new Date("2026-07-20T12:00:00.000Z"); |
There was a problem hiding this comment.
P3: The fixture hardcodes staleAt (2026-07-20) and a fixed reason claiming "23 days" of inactivity, but flagStalledDeal computes meta.daysInactive from the live wall clock, so the stored days keep growing and already disagree with the "23 days" string, making any future assertion on days date-dependent. Derive the date from a fixed offset (e.g. Date.now() - 23*86_400_000) so the fixture stays self-consistent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/stalled-deal.integration.spec.ts, line 11:
<comment>The fixture hardcodes staleAt (2026-07-20) and a fixed reason claiming "23 days" of inactivity, but flagStalledDeal computes meta.daysInactive from the live wall clock, so the stored days keep growing and already disagree with the "23 days" string, making any future assertion on days date-dependent. Derive the date from a fixed offset (e.g. Date.now() - 23*86_400_000) so the fixture stays self-consistent.</comment>
<file context>
@@ -0,0 +1,127 @@
+const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8);
+const domain = `flag-stalled-${suffix}.test`;
+const ownerId = `flag-owner-${suffix}`;
+const staleAt = new Date("2026-07-20T12:00:00.000Z");
+
+let companyId = "";
</file context>
| }, | ||
| _count: { _all: true }, | ||
| }), | ||
| this.db.agentRun.findMany({ |
There was a problem hiding this comment.
P3: The run/action queries are not scoped to team agents while the agents query is. agentDefinition.findMany restricts to TEAM_AGENT_STATUSES (LIVE/PAUSED/ARCHIVED), but the agentRun and agentAction groupBy/findMany queries aggregate over all runs and actions in the workspace regardless of the originating agent's status. As a result a non-team agent (DRAFT, and especially DELETED) can inflate the Runs/Open/Cancelled/token/cost stats while being excluded from the Agents total, making the fleet view internally inconsistent. Consider adding a relation filter on the agent status (e.g. agent: { status: { in: [...TEAM_AGENT_STATUSES] } }) to the run and action queries so the whole report covers the same agent set.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/agent/agent-observability.service.ts, line 50:
<comment>The run/action queries are not scoped to team agents while the agents query is. `agentDefinition.findMany` restricts to `TEAM_AGENT_STATUSES` (LIVE/PAUSED/ARCHIVED), but the `agentRun` and `agentAction` groupBy/findMany queries aggregate over all runs and actions in the workspace regardless of the originating agent's status. As a result a non-team agent (DRAFT, and especially DELETED) can inflate the Runs/Open/Cancelled/token/cost stats while being excluded from the Agents total, making the fleet view internally inconsistent. Consider adding a relation filter on the agent status (e.g. `agent: { status: { in: [...TEAM_AGENT_STATUSES] } }`) to the run and action queries so the whole report covers the same agent set.</comment>
<file context>
@@ -0,0 +1,191 @@
+ },
+ _count: { _all: true },
+ }),
+ this.db.agentRun.findMany({
+ where: { createdAt: { gte: since } },
+ select: {
</file context>
| _count: { select: { actions: true, events: true } }, | ||
| }, | ||
| }), | ||
| this.db.agentAction.groupBy({ |
There was a problem hiding this comment.
P3: The run-side aggregations (runsByStatus, runsByTrigger, runsByLifecycleRole, quality, consumption) are windowed on run.createdAt, but the action-side aggregations (actionsByType, actionsByStatus) are windowed on agentAction.plannedAt. Because a run and its actions carry different timestamps (actions get their own plannedAt at creation), the two halves of this report can cover slightly different 24h spans — e.g. actions planned right before the window are dropped while the run that produced them is counted, and vice-versa. This makes the Run and Action totals not describe exactly the same period, which muddies the 'fleet health (24h)' readout. Consider aligning both on the same timestamp (e.g. run created/started time via the run relation) so the aggregate is internally consistent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/agent/agent-observability.service.ts, line 64:
<comment>The run-side aggregations (runsByStatus, runsByTrigger, runsByLifecycleRole, quality, consumption) are windowed on run.createdAt, but the action-side aggregations (actionsByType, actionsByStatus) are windowed on agentAction.plannedAt. Because a run and its actions carry different timestamps (actions get their own plannedAt at creation), the two halves of this report can cover slightly different 24h spans — e.g. actions planned right before the window are dropped while the run that produced them is counted, and vice-versa. This makes the Run and Action totals not describe exactly the same period, which muddies the 'fleet health (24h)' readout. Consider aligning both on the same timestamp (e.g. run created/started time via the run relation) so the aggregate is internally consistent.</comment>
<file context>
@@ -0,0 +1,191 @@
+ _count: { select: { actions: true, events: true } },
+ },
+ }),
+ this.db.agentAction.groupBy({
+ by: ["type"],
+ where: { plannedAt: { gte: since } },
</file context>
|
|
||
| for (const run of runs) { | ||
| const role = readLifecycleRole(run.version.manifest); | ||
| const roleKey = role ? permittedLifecycleRole(role) : "none"; |
There was a problem hiding this comment.
P3: Unknown lifecycle roles are bucketed as none, not other, and the other bucket is unreachable. readLifecycleRole nullifies any non-qualify/engage/advance/close role before permittedLifecycleRole runs, so the OTHER branch never triggers and invalid values land in none. This contradicts docs/telemetry.md, which says unknown values are counted as other. To match the docs, distinguish an untagged manifest (lifecycleRole absent) from a present-but-invalid value (lifecycleRole present but not in the enum) and route the invalid case to other.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/telemetry/rollup.service.ts, line 308:
<comment>Unknown lifecycle roles are bucketed as none, not other, and the other bucket is unreachable. readLifecycleRole nullifies any non-qualify/engage/advance/close role before permittedLifecycleRole runs, so the OTHER branch never triggers and invalid values land in none. This contradicts docs/telemetry.md, which says unknown values are counted as other. To match the docs, distinguish an untagged manifest (lifecycleRole absent) from a present-but-invalid value (lifecycleRole present but not in the enum) and route the invalid case to other.</comment>
<file context>
@@ -223,6 +230,130 @@ export class RollupService {
+
+ for (const run of runs) {
+ const role = readLifecycleRole(run.version.manifest);
+ const roleKey = role ? permittedLifecycleRole(role) : "none";
+ runsByRole[roleKey] = (runsByRole[roleKey] ?? 0) + 1;
+
</file context>
|
Closing: opened against upstream by mistake. This wave is for the Aerolot deploy path on Aerolot-ai/crm, not a contribution to trycompai/crm. |
Summary
Integrated Aerolot local agentic CRM wave onto
release(from14cd220→ tipde9ad1a).Source branches (all landed in this PR)
optional-capabilities, brand-priority, portrait-priority, identity-matching, calendar-sync, gmail-threads, auto-create, stalled-deal, job-change, meeting-prep, agent-qualify, agent-engage, agent-advance, agent-close, agent-observability, deal-score-forecast
Test plan
bun run check-typesbun run lintbun run test(api/agent/db packages)20260812010000_deal_score_forecastAGENT_URL+ matchingAGENT_BRIDGE_SECRET(Vercel agent)Notes
Aerolot-ai/crm(READ-only ontrycompai/crm)Summary by cubic
Integrates the agentic CRM wave: deploy‑gated lifecycle specialists, stronger identity and hygiene lanes, Gmail/Calendar sync coverage, deal scoring, and team agent observability. Improves safety by keeping lifecycle work recommend‑only and strengthening identity checks before writes.
New Features
qualify,engage,advance,closeas recommend‑only. Adds manifestlifecycleRole, builder support, and UI label. Onlycrm.activity.createandrun.summary; no auto‑send, no stage mutation, no reopen, no finance writes.FULL_AGENTIC_CHECKLISTand documentsRAPIDAPI_KEY,PERPLEXITY_API_KEY,BLOB_READ_WRITE_TOKEN,AGENT_BRIDGE_SECRET.write_deal_intelligencetool). Helpers in@crm/db, sheet UI inapps/app.agents.observabilityAPI and team index card show fleet runs/actions, token counts, and cost. Adds per‑run token/cost in agent history and extends@crm/telemetryallowlist.Migration
packages/db/prisma/migrations/20260812010000_deal_score_forecast.RAPIDAPI_KEY,PERPLEXITY_API_KEY,BLOB_READ_WRITE_TOKEN,AGENT_BRIDGE_SECRET(same on app and agent). Add the Context key in Settings → General.AGENT_URL+ matchingAGENT_BRIDGE_SECRET).Written for commit de9ad1a. Summary will update on new commits.