@agentrysh/mcp 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -6,13 +6,38 @@ import { api } from "./api.js";
6
6
  import { loadConfig, saveConfig } from "./config.js";
7
7
  import { getOnboardingHint } from "./onboarding.js";
8
8
  import { getMemoryPath, readCaseSection, upsertCaseSection, MEMORY_FILENAME, } from "./memory.js";
9
+ import * as frictionTracker from "./friction-tracker.js";
9
10
  import * as fs from "node:fs";
10
11
  export const TOOL_DESCRIPTORS = [
11
12
  {
12
13
  name: "agentry_status",
13
14
  description: "Show what's set up locally and what to do next. Always safe to call. " +
14
- "Use this first if you don't know whether the user has signed up or has a project.",
15
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
15
+ "Use this first if you don't know whether the user has signed up or has a project. " +
16
+ "" +
17
+ "If shapes + flags are passed (same vocabulary as agentry_recipe_requirements), the " +
18
+ "response also includes a coverage_gap report — applicable_count, runnable_count, and " +
19
+ "the events / event-properties still missing for full recipe runnability. Useful for " +
20
+ "re-installs and post-install audits: 'ready, but seat-utilization is one property " +
21
+ "away from working' is a more honest status than just 'ready'.",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ shapes: {
26
+ type: "array",
27
+ items: { type: "string" },
28
+ description: "Optional. App shapes for coverage check (e.g. ['b2b-saas', 'devtools-api']). " +
29
+ "When passed, response includes coverage_gap. Without it, status is project-only.",
30
+ },
31
+ has_revenue: { type: "boolean" },
32
+ is_b2b: { type: "boolean" },
33
+ is_two_sided: { type: "boolean" },
34
+ project_id: {
35
+ type: "string",
36
+ description: "Project id for the coverage check. Defaults to default_project_id.",
37
+ },
38
+ },
39
+ additionalProperties: false,
40
+ },
16
41
  },
17
42
  {
18
43
  name: "agentry_login",
@@ -986,13 +1011,16 @@ export const TOOL_DESCRIPTORS = [
986
1011
  },
987
1012
  {
988
1013
  name: "agentry_verify_install",
989
- description: "Comprehensive sanity check: fires a synthetic error, a synthetic analytics event, and a synthetic " +
990
- "deploy event, then reports which signal types reached agentry. Run this AFTER walking through " +
991
- "agentry_install_guide. " +
992
- "MUST be run with NO skipped signal types on a first-time install — the install is not done until " +
993
- "all three return OK. If any signal returns FAIL, the corresponding wire_* step was skipped or " +
994
- "wired incorrectly; the agent must go back and fix it before declaring the install complete. Do not " +
995
- "report 'installed' to the user with one or two ✅s and a ❌; that's a half-install.",
1014
+ description: "Comprehensive sanity check. Fires a synthetic error, analytics event, and deploy event AND " +
1015
+ " if shapes are provided checks PostHog for the events the user's applicable recipes need. " +
1016
+ "Run this AFTER walking through agentry_install_guide. " +
1017
+ "" +
1018
+ "MUST be run with NO skipped signal types on a first-time install. The install is not done " +
1019
+ "until all three signals are OK AND every event from `required_events` (derived from the app's " +
1020
+ "shape via agentry_recipe_requirements) is actually firing in PostHog. " +
1021
+ "" +
1022
+ "Do not report 'installed' to the user with one or two ✅s and a ❌, or with synthetic ✅s but " +
1023
+ "missing recipe events — that's a half-install.",
996
1024
  inputSchema: {
997
1025
  type: "object",
998
1026
  properties: {
@@ -1003,6 +1031,26 @@ export const TOOL_DESCRIPTORS = [
1003
1031
  description: "ONLY for re-runs after a partial install has already been verified. On a first install, " +
1004
1032
  "leave this empty — all three signal types must verify before the install counts as done.",
1005
1033
  },
1034
+ shapes: {
1035
+ type: "array",
1036
+ items: { type: "string" },
1037
+ description: "App shape(s) the agent detected during investigate_app_structure (e.g. ['b2b-saas', " +
1038
+ "'devtools-api']). When passed, verify_install also queries PostHog for the events the " +
1039
+ "applicable docs recipes need and reports which recipes are blocked. Strongly recommended " +
1040
+ "on first install — without it you only know the pipe works, not that the recipes will run.",
1041
+ },
1042
+ has_revenue: {
1043
+ type: "boolean",
1044
+ description: "Payment processor detected. Used with shapes to filter applicable recipes.",
1045
+ },
1046
+ is_b2b: {
1047
+ type: "boolean",
1048
+ description: "Workspace/account/seat model present. Used with shapes to filter applicable recipes.",
1049
+ },
1050
+ is_two_sided: {
1051
+ type: "boolean",
1052
+ description: "Two-sided marketplace. Used with shapes to filter applicable recipes.",
1053
+ },
1006
1054
  },
1007
1055
  additionalProperties: false,
1008
1056
  },
@@ -1025,6 +1073,49 @@ export const TOOL_DESCRIPTORS = [
1025
1073
  additionalProperties: false,
1026
1074
  },
1027
1075
  },
1076
+ {
1077
+ name: "agentry_recipe_requirements",
1078
+ description: "Recipe-driven install gate. Given the user's detected app shape(s) and revenue/B2B/two-sided " +
1079
+ "flags, returns the *applicable* docs recipes and the UNION of events they need. The install guide " +
1080
+ "uses this output as the canonical event inventory: every event in `required_events` MUST be wired, " +
1081
+ "with the listed `required_event_properties` attached, before agentry_verify_install passes. " +
1082
+ "" +
1083
+ "CALL THIS during agentry_install_guide's inventory step, AFTER categorizing the app, BEFORE writing " +
1084
+ "any track() calls. Don't hand-build an AARRR inventory — let the recipe catalog drive what to " +
1085
+ "instrument so the user can actually run the recipes a week / month later. " +
1086
+ "" +
1087
+ "shapes: comma-sep app shapes from the install-guide vocabulary (b2c-saas, b2b-saas, ecommerce, " +
1088
+ "marketplace, content-media, devtools-api, games, internal-tool, single-purchase, open-source, " +
1089
+ "enterprise-sales, mobile-app). 'universal' recipes always match. " +
1090
+ "has_revenue: true if the app has a payment processor (stripe/paddle/etc.) — gates revenue recipes. " +
1091
+ "is_b2b: true if the app has workspaces/accounts/seats — gates B2B recipes. " +
1092
+ "is_two_sided: true if the app is a marketplace with supply/demand — gates marketplace recipes.",
1093
+ inputSchema: {
1094
+ type: "object",
1095
+ properties: {
1096
+ shapes: {
1097
+ type: "array",
1098
+ items: { type: "string" },
1099
+ description: "App shape(s) detected during investigate_app_structure. Apps can straddle (e.g. a B2B SaaS " +
1100
+ "that's also a devtools API). Pass every shape that fits — applicable recipes are the union.",
1101
+ },
1102
+ has_revenue: {
1103
+ type: "boolean",
1104
+ description: "Payment processor present in the codebase. Gates trial/subscription/churn recipes.",
1105
+ },
1106
+ is_b2b: {
1107
+ type: "boolean",
1108
+ description: "Workspace/account/seat model present. Gates account-health, seat-utilization, etc.",
1109
+ },
1110
+ is_two_sided: {
1111
+ type: "boolean",
1112
+ description: "Two-sided marketplace. Gates supply-side/liquidity recipes.",
1113
+ },
1114
+ },
1115
+ required: ["shapes"],
1116
+ additionalProperties: false,
1117
+ },
1118
+ },
1028
1119
  {
1029
1120
  name: "agentry_run_recipe",
1030
1121
  description: "Run a recipe by id. Returns rows + a render_hint the agent uses to format the answer " +
@@ -1264,12 +1355,19 @@ export const TOOL_DESCRIPTORS = [
1264
1355
  {
1265
1356
  name: "agentry_send_feedback",
1266
1357
  description: "File feedback for the agentry team when something doesn't work or is missing. " +
1267
- "Call this in exactly two situations: " +
1358
+ "Call this in three situations: " +
1268
1359
  "(1) the user explicitly asks for a feature that doesn't exist or expresses frustration that agentry " +
1269
1360
  "isn't doing what they want ('I wish it could…', 'why doesn't it…', 'this doesn't work', " +
1270
1361
  "'feature request: …'); " +
1271
1362
  "(2) you have made 2+ failed attempts at the same task — same MCP tool returning errors, or repeatedly " +
1272
- "failing to find a recipe/route for what the user asked. " +
1363
+ "failing to find a recipe/route for what the user asked; " +
1364
+ "(3) RECIPE PROPOSAL — during agentry_install_guide's plan-confirmation step, the user named a " +
1365
+ "question they want to be able to ask that ISN'T in the docs recipe catalog. Use " +
1366
+ "`kind: 'recipe_proposal'`, quote the user's question in `message`, and put a structured " +
1367
+ "proposal in `agent_note` (the JSON shape described under that field). This is the only path " +
1368
+ "from a customer's install back into the recipe catalog — without it, every novel question " +
1369
+ "stays stranded in chat history. " +
1370
+ "" +
1273
1371
  "File it ONCE per distinct issue per session — don't spam. Quote the user verbatim in `message` where " +
1274
1372
  "possible. Don't apologize repeatedly to the user; just tell them you've logged it.",
1275
1373
  inputSchema: {
@@ -1277,9 +1375,13 @@ export const TOOL_DESCRIPTORS = [
1277
1375
  properties: {
1278
1376
  kind: {
1279
1377
  type: "string",
1280
- enum: ["missing_feature", "bug", "ux_friction", "other"],
1378
+ enum: ["missing_feature", "bug", "ux_friction", "recipe_proposal", "other"],
1281
1379
  description: "missing_feature = capability doesn't exist; bug = something behaves wrong; " +
1282
- "ux_friction = works but is awkward/confusing; other = anything else.",
1380
+ "ux_friction = works but is awkward/confusing; " +
1381
+ "recipe_proposal = user named a question to ask that no docs recipe covers — " +
1382
+ "use during install flow; agent_note MUST include the proposed applies_to / " +
1383
+ "required_events / required_event_properties so the catalog can absorb it; " +
1384
+ "other = anything else.",
1283
1385
  },
1284
1386
  message: {
1285
1387
  type: "string",
@@ -1289,7 +1391,17 @@ export const TOOL_DESCRIPTORS = [
1289
1391
  agent_note: {
1290
1392
  type: "string",
1291
1393
  description: "Optional: what YOU (the agent) were trying to do, which tools you called, what failed. " +
1292
- "Helps the agentry team reproduce.",
1394
+ "Helps the agentry team reproduce. " +
1395
+ "" +
1396
+ "FOR kind='recipe_proposal' THIS FIELD IS REQUIRED and must be JSON-shaped, e.g.: " +
1397
+ "{\"applies_to\": [\"b2b-saas\"], \"requires_revenue\": true, \"required_events\": " +
1398
+ "[\"workspace_created\", \"template_first_action\"], \"required_event_properties\": " +
1399
+ "{\"workspace_created\": [\"starter_template\"]}, \"category\": \"growth\", " +
1400
+ "\"suggested_title\": \"Which starter template has the highest 30-day retention?\", " +
1401
+ "\"detected_shape_evidence\": \"workspaces.starter_template column present at " +
1402
+ "db/schema.ts:23\", \"new_events_wired\": [\"workspace_created.starter_template\", " +
1403
+ "\"template_first_action\"]}. " +
1404
+ "The catalog maintainers paste this shape into a new recipe.md with minimal editing.",
1293
1405
  },
1294
1406
  tool_name: {
1295
1407
  type: "string",
@@ -1498,10 +1610,49 @@ function buildSyntheticEvent() {
1498
1610
  }
1499
1611
  export async function dispatchTool(name, args) {
1500
1612
  const a = args ?? {};
1613
+ let result;
1614
+ try {
1615
+ result = await dispatchToolInner(name, a);
1616
+ }
1617
+ catch (err) {
1618
+ frictionTracker.recordCall({
1619
+ tool: name,
1620
+ ts: Date.now(),
1621
+ ok: false,
1622
+ errorCode: err instanceof Error ? err.message.slice(0, 80) : String(err).slice(0, 80),
1623
+ });
1624
+ // Repeated-failure detector — fires when same tool errors N times in a row.
1625
+ try {
1626
+ frictionTracker.detectRepeatedFailure(loadConfig(), name);
1627
+ }
1628
+ catch { /* swallow — observability never breaks the request */ }
1629
+ return summarizeApiError(err);
1630
+ }
1631
+ // Record success/failure outcome. `error` shape on the result means handler
1632
+ // returned an error envelope without throwing.
1633
+ const ok = !("error" in (result ?? {}));
1634
+ frictionTracker.recordCall({ tool: name, ts: Date.now(), ok });
1635
+ try {
1636
+ if (!ok)
1637
+ frictionTracker.detectRepeatedFailure(loadConfig(), name);
1638
+ if (name === "agentry_install_guide") {
1639
+ frictionTracker.detectGuideRefetched(loadConfig());
1640
+ }
1641
+ }
1642
+ catch { /* swallow */ }
1643
+ return result;
1644
+ }
1645
+ async function dispatchToolInner(name, a) {
1501
1646
  try {
1502
1647
  switch (name) {
1503
1648
  case "agentry_status":
1504
- return handleStatus();
1649
+ return await handleStatus({
1650
+ shapes: Array.isArray(a.shapes) ? a.shapes.map(String) : undefined,
1651
+ has_revenue: a.has_revenue === true,
1652
+ is_b2b: a.is_b2b === true,
1653
+ is_two_sided: a.is_two_sided === true,
1654
+ project_id: a.project_id ? String(a.project_id) : undefined,
1655
+ });
1505
1656
  case "agentry_login":
1506
1657
  return await handleLogin({
1507
1658
  mode: a.mode === "start_only" || a.mode === "poll_once"
@@ -1784,9 +1935,20 @@ export async function dispatchTool(name, args) {
1784
1935
  return await handleVerifyInstall({
1785
1936
  project_id: a.project_id ? String(a.project_id) : undefined,
1786
1937
  skip: Array.isArray(a.skip) ? a.skip.map(String) : [],
1938
+ shapes: Array.isArray(a.shapes) ? a.shapes.map(String) : undefined,
1939
+ has_revenue: a.has_revenue === true,
1940
+ is_b2b: a.is_b2b === true,
1941
+ is_two_sided: a.is_two_sided === true,
1787
1942
  });
1788
1943
  case "agentry_list_recipes":
1789
1944
  return await handleListRecipes(a.category ? String(a.category) : undefined);
1945
+ case "agentry_recipe_requirements":
1946
+ return await handleRecipeRequirements({
1947
+ shapes: Array.isArray(a.shapes) ? a.shapes.map(String) : [],
1948
+ has_revenue: a.has_revenue === true,
1949
+ is_b2b: a.is_b2b === true,
1950
+ is_two_sided: a.is_two_sided === true,
1951
+ });
1790
1952
  case "agentry_run_recipe":
1791
1953
  return await handleRunRecipe({
1792
1954
  recipe_id: String(a.recipe_id ?? ""),
@@ -1908,14 +2070,15 @@ export async function dispatchTool(name, args) {
1908
2070
  return summarizeApiError(err);
1909
2071
  }
1910
2072
  }
2073
+ // dispatchToolInner is closed above. Below: handlers.
1911
2074
  // ---------------------------------------------------------------------------
1912
2075
  // Tool handlers
1913
2076
  // ---------------------------------------------------------------------------
1914
- function handleStatus() {
2077
+ async function handleStatus(input) {
1915
2078
  const cfg = loadConfig();
1916
2079
  const hint = getOnboardingHint(cfg);
1917
2080
  const projectIds = Object.keys(cfg.projects);
1918
- return {
2081
+ const baseResult = {
1919
2082
  server_url: cfg.server_url,
1920
2083
  has_api_key: Boolean(cfg.api_key),
1921
2084
  api_key_prefix: cfg.api_key ? `${cfg.api_key.slice(0, 8)}…` : null,
@@ -1932,8 +2095,90 @@ function handleStatus() {
1932
2095
  };
1933
2096
  }),
1934
2097
  onboarding: hint,
1935
- next_steps: [hint.message, hint.next_action],
1936
2098
  };
2099
+ // Coverage check — only runs when shapes are passed AND we have a project + api key.
2100
+ // Independent of the onboarding hint above: status can be "ready" (synthetic signals
2101
+ // verified) while still having coverage gaps (events firing without required props).
2102
+ if (input?.shapes && input.shapes.length > 0 && cfg.api_key) {
2103
+ const projectId = input.project_id ?? cfg.default_project_id;
2104
+ if (projectId) {
2105
+ try {
2106
+ const reqs = await api.recipeRequirements(cfg, {
2107
+ shapes: input.shapes,
2108
+ has_revenue: input.has_revenue === true,
2109
+ is_b2b: input.is_b2b === true,
2110
+ is_two_sided: input.is_two_sided === true,
2111
+ });
2112
+ const events = await api.listEventNames(cfg, projectId);
2113
+ const eventNames = new Set([
2114
+ ...(events.server_emitted ?? []),
2115
+ ...(events.analytics_events ?? []).map((e) => e.event),
2116
+ ]);
2117
+ // Sample property keys for events that ARE present and have required props.
2118
+ const checkProps = new Set();
2119
+ for (const rec of reqs.applicable) {
2120
+ for (const ev of Object.keys(rec.required_event_properties || {})) {
2121
+ if (eventNames.has(ev))
2122
+ checkProps.add(ev);
2123
+ }
2124
+ }
2125
+ let sampledKeys = {};
2126
+ if (checkProps.size > 0) {
2127
+ try {
2128
+ const r = await api.eventPropertyKeys(cfg, projectId, [...checkProps]);
2129
+ sampledKeys = r.keys ?? {};
2130
+ }
2131
+ catch { /* best-effort */ }
2132
+ }
2133
+ const blocked = [];
2134
+ const runnableIds = [];
2135
+ for (const rec of reqs.applicable) {
2136
+ const missingEv = rec.required_events.filter((e) => !eventNames.has(e));
2137
+ const missingProp = {};
2138
+ for (const [ev, props] of Object.entries(rec.required_event_properties || {})) {
2139
+ if (!eventNames.has(ev))
2140
+ continue;
2141
+ const present = new Set(sampledKeys[ev] ?? []);
2142
+ const m = props.filter((p) => !present.has(p));
2143
+ if (m.length > 0)
2144
+ missingProp[ev] = m;
2145
+ }
2146
+ if (missingEv.length === 0 && Object.keys(missingProp).length === 0)
2147
+ runnableIds.push(rec.id);
2148
+ else
2149
+ blocked.push({ id: rec.id, title: rec.title, missing_events: missingEv, missing_properties: missingProp });
2150
+ }
2151
+ const missingEvUnion = new Set();
2152
+ const missingPropUnion = new Set();
2153
+ for (const b of blocked) {
2154
+ for (const e of b.missing_events)
2155
+ missingEvUnion.add(e);
2156
+ for (const [ev, props] of Object.entries(b.missing_properties))
2157
+ for (const p of props)
2158
+ missingPropUnion.add(`${ev}.${p}`);
2159
+ }
2160
+ baseResult.coverage_gap = {
2161
+ shapes: input.shapes,
2162
+ applicable_count: reqs.applicable_count,
2163
+ runnable_count: runnableIds.length,
2164
+ missing_events: [...missingEvUnion].sort(),
2165
+ missing_event_properties: [...missingPropUnion].sort(),
2166
+ blocked_recipes: blocked.map((b) => b.id),
2167
+ summary: `${runnableIds.length}/${reqs.applicable_count} applicable recipes runnable. ` +
2168
+ (missingEvUnion.size > 0 ? `Missing events: ${[...missingEvUnion].sort().join(", ")}. ` : "") +
2169
+ (missingPropUnion.size > 0 ? `Missing properties: ${[...missingPropUnion].sort().join(", ")}.` : ""),
2170
+ };
2171
+ }
2172
+ catch (err) {
2173
+ baseResult.coverage_gap = {
2174
+ error: err instanceof Error ? err.message : String(err),
2175
+ note: "coverage_gap requires logged-in api key + an existing project. Skipping.",
2176
+ };
2177
+ }
2178
+ }
2179
+ }
2180
+ baseResult.next_steps = [hint.message, hint.next_action];
2181
+ return baseResult;
1937
2182
  }
1938
2183
  async function handleLogin(input) {
1939
2184
  const cfg = loadConfig();
@@ -3297,10 +3542,21 @@ async function handleAnalyticsQuery(input) {
3297
3542
  };
3298
3543
  }
3299
3544
  const resp = await api.analyticsQuery(cfg, projectId, input.query);
3545
+ // Friction: query references a named event in WHERE and got 0 rows back.
3546
+ // Heuristic-driven (detector inspects the query string); fires only when
3547
+ // a specific event name returned empty — most likely a missing-instrumentation
3548
+ // or wrong-event-name signal worth flagging for AI review.
3549
+ const rowsReturned = Array.isArray(resp.results) ? resp.results.length : 0;
3550
+ const frictionFp = frictionTracker.detectEmptyAnalyticsQuery(cfg, {
3551
+ query: input.query,
3552
+ project_id: projectId,
3553
+ rows_returned: rowsReturned,
3554
+ });
3300
3555
  return {
3301
3556
  project_id: projectId,
3302
3557
  ...resp,
3303
- next_action: "Interpret the rows. If you suspect a regression, call agentry_list_deploys to see if a deploy correlates.",
3558
+ next_action: "Interpret the rows. If you suspect a regression, call agentry_list_deploys to see if a deploy correlates." +
3559
+ frictionTracker.frictionNoticeSuffix(frictionFp),
3304
3560
  };
3305
3561
  }
3306
3562
  // ---------------------------------------------------------------------------
@@ -3433,22 +3689,151 @@ async function handleVerifyInstall(input) {
3433
3689
  "call agentry_repair_analytics, then re-run verify. "
3434
3690
  : "") +
3435
3691
  "For each failed type, re-read its corresponding step in agentry_install_guide and fix.";
3692
+ // ── Recipe runnability check ───────────────────────────────────────────
3693
+ // If the agent passed shapes, derive the required-events set and check
3694
+ // PostHog for each. Report per-recipe runnability so the agent knows
3695
+ // which docs recipes the user will actually be able to run.
3696
+ let recipeCheck = {
3697
+ requested: false,
3698
+ applicable_count: 0,
3699
+ runnable_count: 0,
3700
+ runnable: [],
3701
+ blocked: [],
3702
+ missing_events: [],
3703
+ missing_event_properties: [],
3704
+ };
3705
+ if (input.shapes && input.shapes.length > 0 && checks.analytics?.ok) {
3706
+ try {
3707
+ const cfg2 = loadConfig();
3708
+ const reqs = await api.recipeRequirements(cfg2, {
3709
+ shapes: input.shapes,
3710
+ has_revenue: input.has_revenue === true,
3711
+ is_b2b: input.is_b2b === true,
3712
+ is_two_sided: input.is_two_sided === true,
3713
+ });
3714
+ // Snapshot the distinct event names currently firing in this project.
3715
+ // listEventNames returns both server-emitted (PostHog $events) and analytics_events
3716
+ // (PostHog person events with counts). Union them — recipes can need either.
3717
+ const events = await api.listEventNames(cfg2, r.id);
3718
+ const eventNames = new Set([
3719
+ ...(events.server_emitted ?? []),
3720
+ ...(events.analytics_events ?? []).map((e) => e.event),
3721
+ ]);
3722
+ // Sample property keys for the union of required events present, so we
3723
+ // can confirm not just event presence but that the properties recipes
3724
+ // depend on are actually being attached.
3725
+ const eventsNeedingProps = [];
3726
+ const propsByEvent = {};
3727
+ for (const rec of reqs.applicable) {
3728
+ for (const [ev, props] of Object.entries(rec.required_event_properties || {})) {
3729
+ if (!eventNames.has(ev))
3730
+ continue; // event missing entirely; separate check handles
3731
+ eventsNeedingProps.push(ev);
3732
+ for (const p of props) {
3733
+ propsByEvent[ev] = propsByEvent[ev] || [];
3734
+ if (!propsByEvent[ev].includes(p))
3735
+ propsByEvent[ev].push(p);
3736
+ }
3737
+ }
3738
+ }
3739
+ let sampledKeys = {};
3740
+ if (eventsNeedingProps.length > 0) {
3741
+ try {
3742
+ const resp = await api.eventPropertyKeys(cfg2, r.id, [...new Set(eventsNeedingProps)]);
3743
+ sampledKeys = resp.keys ?? {};
3744
+ }
3745
+ catch {
3746
+ // best-effort — if property sampling fails, fall back to event-presence-only
3747
+ }
3748
+ }
3749
+ const runnable = [];
3750
+ const blocked = [];
3751
+ for (const rec of reqs.applicable) {
3752
+ const missingEvents = rec.required_events.filter((ev) => !eventNames.has(ev));
3753
+ const missingProps = {};
3754
+ for (const [ev, props] of Object.entries(rec.required_event_properties || {})) {
3755
+ if (!eventNames.has(ev))
3756
+ continue;
3757
+ const present = new Set(sampledKeys[ev] ?? []);
3758
+ const missing = props.filter((p) => !present.has(p));
3759
+ if (missing.length > 0)
3760
+ missingProps[ev] = missing;
3761
+ }
3762
+ if (missingEvents.length === 0 && Object.keys(missingProps).length === 0) {
3763
+ runnable.push(rec.id);
3764
+ }
3765
+ else {
3766
+ blocked.push({
3767
+ id: rec.id,
3768
+ title: rec.title,
3769
+ missing_events: missingEvents,
3770
+ missing_properties: missingProps,
3771
+ });
3772
+ }
3773
+ }
3774
+ const missingUnion = new Set();
3775
+ const missingPropUnion = new Set();
3776
+ for (const b of blocked) {
3777
+ for (const m of b.missing_events)
3778
+ missingUnion.add(m);
3779
+ for (const [ev, props] of Object.entries(b.missing_properties)) {
3780
+ for (const p of props)
3781
+ missingPropUnion.add(`${ev}.${p}`);
3782
+ }
3783
+ }
3784
+ recipeCheck = {
3785
+ requested: true,
3786
+ applicable_count: reqs.applicable_count,
3787
+ runnable_count: runnable.length,
3788
+ runnable,
3789
+ blocked,
3790
+ missing_events: [...missingUnion].sort(),
3791
+ missing_event_properties: [...missingPropUnion].sort(),
3792
+ };
3793
+ }
3794
+ catch (err) {
3795
+ recipeCheck.skipped_reason = err instanceof Error ? err.message : String(err);
3796
+ }
3797
+ }
3798
+ else if (input.shapes && input.shapes.length > 0) {
3799
+ recipeCheck.skipped_reason = "analytics signal failed — fix analytics first, then re-run verify with shapes";
3800
+ }
3801
+ else {
3802
+ recipeCheck.skipped_reason =
3803
+ "no shapes provided — pass shapes/has_revenue/is_b2b/is_two_sided to verify recipe runnability";
3804
+ }
3805
+ const recipeFailing = recipeCheck.requested && recipeCheck.runnable_count < recipeCheck.applicable_count;
3806
+ const allOk = failed.length === 0 && !recipeFailing;
3436
3807
  return {
3437
- ok: failed.length === 0,
3438
- summary: `${passed.length}/${Object.keys(checks).length} signal types verified`,
3808
+ ok: allOk,
3809
+ summary: `${passed.length}/${Object.keys(checks).length} signal types verified` +
3810
+ (recipeCheck.requested
3811
+ ? `; ${recipeCheck.runnable_count}/${recipeCheck.applicable_count} applicable recipes runnable`
3812
+ : ""),
3439
3813
  passed,
3440
3814
  failed,
3441
3815
  checks,
3816
+ recipe_check: recipeCheck,
3442
3817
  suggested_next_steps: nextSuggestions,
3443
- next_action: failed.length === 0 && nextSuggestions.length > 0
3444
- ? baseAction +
3445
- " Now offer the user this menu of post-install prompts:\n" +
3446
- nextSuggestions
3447
- .slice(0, 5)
3448
- .map((s, i) => ` ${i + 1}. ${s.title} ${s.description}`)
3449
- .join("\n") +
3450
- "\nWhen the user picks one, paste its `prompt_template` as their next prompt (or just execute the listed `uses`)."
3451
- : baseAction,
3818
+ next_action: recipeFailing
3819
+ ? `Install is HALF-DONE. Synthetic signals work, but ${recipeCheck.applicable_count - recipeCheck.runnable_count} of ${recipeCheck.applicable_count} applicable recipes are blocked.` +
3820
+ (recipeCheck.missing_events.length > 0
3821
+ ? ` Missing events: ${recipeCheck.missing_events.join(", ")}.`
3822
+ : "") +
3823
+ (recipeCheck.missing_event_properties.length > 0
3824
+ ? ` Missing required properties (event.property): ${recipeCheck.missing_event_properties.join(", ")} — events fire but lack required properties.`
3825
+ : "") + " " +
3826
+ `Go back to the install-guide step \`track_comprehensively_for_this_app\` and wire the missing events. ` +
3827
+ `Re-run agentry_verify_install with the same shapes when done. Do NOT tell the user 'installed' until this is 0 missing.`
3828
+ : failed.length === 0 && nextSuggestions.length > 0
3829
+ ? baseAction +
3830
+ " Now offer the user this menu of post-install prompts:\n" +
3831
+ nextSuggestions
3832
+ .slice(0, 5)
3833
+ .map((s, i) => ` ${i + 1}. ${s.title} — ${s.description}`)
3834
+ .join("\n") +
3835
+ "\nWhen the user picks one, paste its `prompt_template` as their next prompt (or just execute the listed `uses`)."
3836
+ : baseAction,
3452
3837
  };
3453
3838
  }
3454
3839
  // ---------------------------------------------------------------------------
@@ -3464,6 +3849,20 @@ async function handleListRecipes(category) {
3464
3849
  "If nothing matches, call `agentry_query_docs` to compose ad-hoc HogQL via `agentry_analytics_query`.",
3465
3850
  };
3466
3851
  }
3852
+ async function handleRecipeRequirements(input) {
3853
+ if (input.shapes.length === 0) {
3854
+ return {
3855
+ error: {
3856
+ code: "missing_shapes",
3857
+ message: "shapes is required (e.g. ['b2c-saas'], ['devtools-api', 'b2b-saas'])",
3858
+ next_action: "Re-call agentry_recipe_requirements with the app shape(s) detected during " +
3859
+ "investigate_app_structure. See the description for the allowed vocabulary.",
3860
+ },
3861
+ };
3862
+ }
3863
+ const cfg = loadConfig();
3864
+ return await api.recipeRequirements(cfg, input);
3865
+ }
3467
3866
  async function handleRunRecipe(input) {
3468
3867
  if (!input.recipe_id) {
3469
3868
  return {
@@ -3495,9 +3894,19 @@ async function handleRunRecipe(input) {
3495
3894
  };
3496
3895
  }
3497
3896
  const resp = await api.runRecipe(cfg, projectId, input.recipe_id, input.params);
3897
+ // Aggressive friction detection: a recipe that runs without errors but
3898
+ // returns 0 rows almost always means required events aren't firing. Fire
3899
+ // feedback to the agentry team for AI review.
3900
+ const rowsReturned = Array.isArray(resp.rows) ? resp.rows.length : 0;
3901
+ const frictionFp = frictionTracker.detectEmptyRecipe(cfg, {
3902
+ recipe_id: input.recipe_id,
3903
+ project_id: projectId,
3904
+ rows_returned: rowsReturned,
3905
+ });
3498
3906
  return {
3499
3907
  project_id: projectId,
3500
3908
  ...resp,
3909
+ next_action: (resp.next_action ?? "") + frictionTracker.frictionNoticeSuffix(frictionFp),
3501
3910
  };
3502
3911
  }
3503
3912
  async function handleQueryDocs() {