@whisperr/wizard 0.3.0 → 0.4.0

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.
Files changed (2) hide show
  1. package/dist/index.js +449 -46
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "url";
8
8
  // src/cli.ts
9
9
  import { resolve as resolve2 } from "path";
10
10
  import * as p from "@clack/prompts";
11
+ import open2 from "open";
11
12
 
12
13
  // src/core/config.ts
13
14
  var DEFAULT_API_BASE = "https://api.whisperr.net";
@@ -1016,42 +1017,67 @@ var divider = theme.dim("\u2500".repeat(48));
1016
1017
  // src/core/auth.ts
1017
1018
  async function authenticate(config) {
1018
1019
  if (config.offline) return offlineSession();
1020
+ const auth = await startDeviceAuth(config);
1021
+ const url = auth.verificationUrlComplete ?? auth.verificationUrl;
1022
+ try {
1023
+ await open(url);
1024
+ } catch {
1025
+ }
1026
+ return auth.poll();
1027
+ }
1028
+ async function startDeviceAuth(config) {
1019
1029
  const authorize = await fetchJson(
1020
1030
  `${config.apiBaseUrl}/wizard/device/authorize`,
1021
1031
  { method: "POST", body: JSON.stringify({ client: "whisperr-wizard" }) }
1022
1032
  );
1023
- const url = authorize.verification_uri_complete ?? authorize.verification_uri;
1024
1033
  const intervalMs = (authorize.interval ?? 5) * 1e3;
1025
1034
  const deadline = Date.now() + (authorize.expires_in ?? 600) * 1e3;
1026
- try {
1027
- await open(url);
1028
- } catch {
1029
- }
1030
- let wait = intervalMs;
1031
- while (Date.now() < deadline) {
1032
- await sleep(wait);
1033
- const res = await fetch(
1034
- `${config.apiBaseUrl}/wizard/device/token`,
1035
- {
1036
- method: "POST",
1037
- headers: { "content-type": "application/json" },
1038
- body: JSON.stringify({ device_code: authorize.device_code })
1035
+ return {
1036
+ verificationUrl: authorize.verification_uri,
1037
+ verificationUrlComplete: authorize.verification_uri_complete,
1038
+ userCode: authorize.user_code,
1039
+ async poll() {
1040
+ let wait = intervalMs;
1041
+ while (Date.now() < deadline) {
1042
+ await sleep(wait);
1043
+ const res = await fetch(
1044
+ `${config.apiBaseUrl}/wizard/device/token`,
1045
+ {
1046
+ method: "POST",
1047
+ headers: { "content-type": "application/json" },
1048
+ body: JSON.stringify({ device_code: authorize.device_code })
1049
+ }
1050
+ );
1051
+ if (res.ok) {
1052
+ const tok = await res.json();
1053
+ return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
1054
+ }
1055
+ if (res.status === 429) {
1056
+ wait += 2e3;
1057
+ continue;
1058
+ }
1059
+ if (res.status === 428) continue;
1060
+ throw new Error(
1061
+ `Authorization failed (${res.status}). Re-run the wizard and use the printed link and code to try again.`
1062
+ );
1039
1063
  }
1040
- );
1041
- if (res.ok) {
1042
- const tok = await res.json();
1043
- return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
1044
- }
1045
- if (res.status === 429) {
1046
- wait += 2e3;
1047
- continue;
1064
+ throw new Error(
1065
+ "Authorization timed out. Re-run the wizard and use the printed link and code to try again."
1066
+ );
1048
1067
  }
1049
- if (res.status === 428) continue;
1050
- throw new Error(
1051
- `Authorization failed (${res.status}). Run the wizard again to retry.`
1052
- );
1053
- }
1054
- throw new Error("Authorization timed out. Run the wizard again.");
1068
+ };
1069
+ }
1070
+ function startSessionKeepalive(config, session, intervalMs = 5 * 6e4) {
1071
+ if (config.offline) return () => {
1072
+ };
1073
+ const timer = setInterval(() => {
1074
+ fetch(`${config.apiBaseUrl}/wizard/first-event`, {
1075
+ headers: { authorization: `Bearer ${session.token}` }
1076
+ }).catch(() => {
1077
+ });
1078
+ }, intervalMs);
1079
+ timer.unref?.();
1080
+ return () => clearInterval(timer);
1055
1081
  }
1056
1082
  function offlineSession() {
1057
1083
  return {
@@ -1148,7 +1174,9 @@ function mockManifest(appId, config) {
1148
1174
  }
1149
1175
 
1150
1176
  // src/core/agent.ts
1151
- import { query } from "@anthropic-ai/claude-agent-sdk";
1177
+ import {
1178
+ query
1179
+ } from "@anthropic-ai/claude-agent-sdk";
1152
1180
 
1153
1181
  // src/core/git.ts
1154
1182
  import { spawn } from "child_process";
@@ -1403,6 +1431,71 @@ async function submitAdditions(config, session, target9, repoFingerprint2, oppor
1403
1431
  }
1404
1432
  return await res.json();
1405
1433
  }
1434
+ async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
1435
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
1436
+ method: "POST",
1437
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1438
+ headers: {
1439
+ "Content-Type": "application/json",
1440
+ Authorization: `Bearer ${session.token}`
1441
+ },
1442
+ body: JSON.stringify({
1443
+ target: target9,
1444
+ repoFingerprint: repoFingerprint2,
1445
+ events: opportunities.events,
1446
+ interventions: opportunities.interventions
1447
+ })
1448
+ });
1449
+ if (res.status === 404) return null;
1450
+ if (!res.ok) {
1451
+ const body = await res.text().catch(() => "");
1452
+ throw new Error(
1453
+ `Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1454
+ );
1455
+ }
1456
+ return await res.json();
1457
+ }
1458
+ async function fetchSuggestions(config, session, statuses) {
1459
+ if (config.offline) return [];
1460
+ try {
1461
+ const statusQuery = statuses.map(encodeURIComponent).join(",");
1462
+ const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
1463
+ const res = await fetch(url, {
1464
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1465
+ headers: { Authorization: `Bearer ${session.token}` }
1466
+ });
1467
+ if (!res.ok) return [];
1468
+ const body = await res.json();
1469
+ return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
1470
+ } catch {
1471
+ return [];
1472
+ }
1473
+ }
1474
+ function isSuggestionRecord(value) {
1475
+ if (typeof value !== "object" || value === null) return false;
1476
+ const record = value;
1477
+ return typeof record.id === "string" && (record.kind === "event" || record.kind === "intervention") && typeof record.code === "string" && (record.status === "proposed" || record.status === "approved" || record.status === "rejected" || record.status === "integrated") && typeof record.payload === "object" && record.payload !== null;
1478
+ }
1479
+ async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
1480
+ if (config.offline) return false;
1481
+ try {
1482
+ const res = await fetch(
1483
+ `${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
1484
+ {
1485
+ method: "POST",
1486
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1487
+ headers: {
1488
+ "Content-Type": "application/json",
1489
+ Authorization: `Bearer ${session.token}`
1490
+ },
1491
+ body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
1492
+ }
1493
+ );
1494
+ return res.status === 200;
1495
+ } catch {
1496
+ return false;
1497
+ }
1498
+ }
1406
1499
  function asArray(value) {
1407
1500
  return Array.isArray(value) ? value : [];
1408
1501
  }
@@ -1458,6 +1551,7 @@ function coerceEvent(entry) {
1458
1551
  description: asString(e.description),
1459
1552
  ...side ? { side } : {},
1460
1553
  rationale: asString(e.rationale),
1554
+ expectedEffect: asString(e.expectedEffect),
1461
1555
  confidence: asConfidence(e.confidence),
1462
1556
  ...properties.length ? { properties } : {},
1463
1557
  ...links.length ? { links } : {}
@@ -1482,6 +1576,7 @@ function coerceIntervention(entry) {
1482
1576
  label: asString(i.label),
1483
1577
  description: asString(i.description),
1484
1578
  rationale: asString(i.rationale),
1579
+ expectedEffect: asString(i.expectedEffect),
1485
1580
  confidence: asConfidence(i.confidence),
1486
1581
  ...links.length ? { links } : {}
1487
1582
  };
@@ -1682,16 +1777,20 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1682
1777
  ];
1683
1778
  return [
1684
1779
  "UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
1685
- "While auditing you read this app's real lifecycle. If you found churn-",
1686
- "relevant moments the plan does NOT cover \u2014 e.g. a recurring-payment or",
1687
- "cancellation path with no event, a support/refund flow worth a retention",
1688
- "play \u2014 record them as PROPOSALS. Do NOT add track() calls for them.",
1780
+ "While auditing you read this app's real lifecycle. Now sweep it",
1781
+ "deliberately: walk each surface the plan should care about \u2014 signup and",
1782
+ "auth, billing/payments (renewals, failures, upgrades, downgrades,",
1783
+ "cancellation), the core engagement loop, support/feedback/refund flows,",
1784
+ "sharing/invites, and any expiry or dormancy mechanics you saw \u2014 and for",
1785
+ "each one ask: does a churn-relevant moment happen here that the plan does",
1786
+ "NOT cover? Record those as PROPOSALS. Do NOT add track() calls for them.",
1689
1787
  `Write a single JSON file at the repo root named ${opportunitiesFile}:`,
1690
1788
  "",
1691
1789
  "{",
1692
1790
  ' "events": [{',
1693
1791
  ' "code": "snake_case_event", "label": "...", "description": "...",',
1694
1792
  ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1793
+ ' "expectedEffect": "one sentence: what improves if this is adopted",',
1695
1794
  ' "confidence": 0.0-1.0,',
1696
1795
  ' "properties": [{"name": "...", "description": "...", "required": false}],',
1697
1796
  ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
@@ -1699,6 +1798,7 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1699
1798
  ' "interventions": [{',
1700
1799
  ' "code": "snake_case_strategy", "label": "...", "description": "...",',
1701
1800
  ' "rationale": "...", "confidence": 0.0-1.0,',
1801
+ ' "expectedEffect": "one sentence: what improves if this is adopted",',
1702
1802
  ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1703
1803
  " }]",
1704
1804
  "}",
@@ -1708,8 +1808,14 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1708
1808
  ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1709
1809
  " Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
1710
1810
  "- Every proposal needs concrete code evidence in its rationale (file/flow),",
1711
- " not speculation. 0-3 events and 0-2 interventions is the normal range;",
1712
- " an empty file or no file at all is a perfectly good outcome.",
1811
+ " not speculation. VERIFY before you write: re-open the file you are citing",
1812
+ " and confirm the flow exists as described \u2014 a false suggestion costs more",
1813
+ " trust than a missed one, so drop anything you cannot point at real code.",
1814
+ "- Be thorough, not shy: a feature-rich product typically yields 3-6 solid",
1815
+ " event proposals and 1-3 interventions. An empty file is only the right",
1816
+ " answer when the plan genuinely already covers the product's lifecycle \u2014",
1817
+ " never because you stopped looking early.",
1818
+ "- Every proposal should include expectedEffect: one sentence explaining what adoption should improve.",
1713
1819
  "- Link each proposed event to the intervention(s) it should feed (existing",
1714
1820
  " codes or ones you propose in the same file).",
1715
1821
  "- This file is metadata for the wizard, not app code \u2014 write it and move on."
@@ -1730,11 +1836,20 @@ function coverageNote(coverage) {
1730
1836
  import { isAbsolute, relative, resolve } from "path";
1731
1837
  var SECRET_MATERIAL_DENIAL = "blocked: secret material - reference the variable name in .env.example instead of reading the real file";
1732
1838
  var WRITE_OUTSIDE_REPO_DENIAL = "blocked: writes must stay inside the target repository";
1839
+ var SENSITIVE_WRITE_DENIAL = "blocked: refusing to write CI/git configuration";
1733
1840
  var BASH_ALLOWLIST_DENIAL = "blocked: command is outside the Bash allowlist - use package install/add commands, mkdir, or git status/diff/log only";
1734
1841
  var CHAINED_COMMAND_DENIAL = "blocked: chained or substituted shell command - run one command at a time";
1735
1842
  var ENV_EXAMPLES = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
1736
1843
  var SECRET_DIRECTORIES = /* @__PURE__ */ new Set([".aws", ".ssh", ".gnupg"]);
1737
1844
  var SECRET_EXACT_FILES = /* @__PURE__ */ new Set([".netrc", ".npmrc", ".pypirc"]);
1845
+ var SENSITIVE_WRITE_DIRECTORIES = /* @__PURE__ */ new Set([".git", ".circleci", ".buildkite"]);
1846
+ var SENSITIVE_WRITE_EXACT_FILES = /* @__PURE__ */ new Set([
1847
+ ".gitlab-ci.yml",
1848
+ "azure-pipelines.yml",
1849
+ "bitbucket-pipelines.yml",
1850
+ ".drone.yml",
1851
+ "jenkinsfile"
1852
+ ]);
1738
1853
  var SECRET_SUFFIXES = [
1739
1854
  ".pem",
1740
1855
  ".key",
@@ -1818,6 +1933,9 @@ function evaluateWrite(input, context) {
1818
1933
  if (!isPathInsideRepo(pathValue, context.repoPath)) {
1819
1934
  return { behavior: "deny", message: WRITE_OUTSIDE_REPO_DENIAL };
1820
1935
  }
1936
+ if (isSensitiveWritePath(pathValue, context.repoPath)) {
1937
+ return { behavior: "deny", message: SENSITIVE_WRITE_DENIAL };
1938
+ }
1821
1939
  return { behavior: "allow" };
1822
1940
  }
1823
1941
  function evaluateBash(input) {
@@ -1907,6 +2025,19 @@ function firstString(input, keys) {
1907
2025
  function normalizePathLike(value) {
1908
2026
  return stripOuterQuotes(value.trim()).replace(/\\/g, "/");
1909
2027
  }
2028
+ function repoRelativePath(pathValue, repoPath) {
2029
+ const repoRoot = resolve(repoPath);
2030
+ const candidate = isAbsolute(pathValue) ? resolve(pathValue) : resolve(repoRoot, pathValue);
2031
+ return normalizePathLike(relative(repoRoot, candidate));
2032
+ }
2033
+ function isSensitiveWritePath(pathValue, repoPath) {
2034
+ const parts = repoRelativePath(pathValue, repoPath).split("/").map((part) => stripOuterQuotes(part.trim().toLowerCase())).filter(Boolean);
2035
+ const [first, second] = parts;
2036
+ if (!first) return false;
2037
+ if (SENSITIVE_WRITE_DIRECTORIES.has(first)) return true;
2038
+ if (first === ".github" && second === "workflows") return true;
2039
+ return parts.length === 1 && SENSITIVE_WRITE_EXACT_FILES.has(first);
2040
+ }
1910
2041
  function stripOuterQuotes(value) {
1911
2042
  if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1912
2043
  return value.slice(1, -1);
@@ -2672,6 +2803,7 @@ async function runPass(opts) {
2672
2803
  thinking: { type: "adaptive" },
2673
2804
  effort,
2674
2805
  tools: [...allowedTools],
2806
+ hooks: buildToolPolicyHooks(repoPath),
2675
2807
  canUseTool: createToolPermissionCallback(repoPath),
2676
2808
  maxTurns,
2677
2809
  // Native SDK hard cost cap. Stops this pass with an `error_max_budget_usd`
@@ -2708,6 +2840,32 @@ async function runPass(opts) {
2708
2840
  }
2709
2841
  return { summary, costUsd, ok, maxedOut };
2710
2842
  }
2843
+ function buildToolPolicyHooks(repoPath) {
2844
+ return {
2845
+ PreToolUse: [
2846
+ {
2847
+ hooks: [
2848
+ async (input) => {
2849
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
2850
+ const decision = evaluateToolUse(input.tool_name, toolInputRecord(input.tool_input), {
2851
+ repoPath
2852
+ });
2853
+ if (decision.behavior === "allow") {
2854
+ return { continue: true };
2855
+ }
2856
+ return {
2857
+ hookSpecificOutput: {
2858
+ hookEventName: "PreToolUse",
2859
+ permissionDecision: "deny",
2860
+ permissionDecisionReason: decision.message
2861
+ }
2862
+ };
2863
+ }
2864
+ ]
2865
+ }
2866
+ ]
2867
+ };
2868
+ }
2711
2869
  function createToolPermissionCallback(repoPath) {
2712
2870
  return async (toolName, input, options) => {
2713
2871
  const decision = evaluateToolUse(toolName, input, { repoPath });
@@ -2718,6 +2876,12 @@ function createToolPermissionCallback(repoPath) {
2718
2876
  };
2719
2877
  };
2720
2878
  }
2879
+ function toolInputRecord(input) {
2880
+ if (input && typeof input === "object" && !Array.isArray(input)) {
2881
+ return input;
2882
+ }
2883
+ return {};
2884
+ }
2721
2885
  function applyModelAuthEnv(config, session) {
2722
2886
  if (config.directAnthropicKey) {
2723
2887
  process.env.ANTHROPIC_API_KEY = config.directAnthropicKey;
@@ -2844,6 +3008,12 @@ function tail2(s) {
2844
3008
  }
2845
3009
 
2846
3010
  // src/core/report.ts
3011
+ function scrubSummary(text) {
3012
+ return scrubTerminalControls(text).replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]").replace(/sk-[A-Za-z0-9]{16,}/g, "[redacted]").replace(/AKIA[0-9A-Z]{16}/g, "[redacted]").replace(/Bearer\s+[A-Za-z0-9._-]+/g, "[redacted]").replace(
3013
+ /\b(ANTHROPIC_(?:AUTH_TOKEN|API_KEY))\s*=\s*["']?[^"'\s]+["']?/gi,
3014
+ "$1=[redacted]"
3015
+ );
3016
+ }
2847
3017
  async function postRunReport(config, session, report) {
2848
3018
  if (config.offline) return { ok: true };
2849
3019
  try {
@@ -2879,6 +3049,121 @@ function detailSlice(detail) {
2879
3049
  const sliced = detail.slice(0, 200).trim();
2880
3050
  return sliced || void 0;
2881
3051
  }
3052
+ function scrubTerminalControls(value) {
3053
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
3054
+ }
3055
+
3056
+ // src/core/gapreport.ts
3057
+ function buildGapReport(input) {
3058
+ const outcomes = new Map(
3059
+ input.events.map((event) => [
3060
+ event.eventType,
3061
+ {
3062
+ status: event.status,
3063
+ reason: trimmedReason(event.reason)
3064
+ }
3065
+ ])
3066
+ );
3067
+ const interventions = /* @__PURE__ */ new Map();
3068
+ for (const event of input.manifest.events) {
3069
+ for (const intervention of event.interventions ?? []) {
3070
+ let collected = interventions.get(intervention.code);
3071
+ if (!collected) {
3072
+ collected = {
3073
+ code: intervention.code,
3074
+ label: intervention.label,
3075
+ drivingEvents: [],
3076
+ drivingEventSet: /* @__PURE__ */ new Set()
3077
+ };
3078
+ interventions.set(intervention.code, collected);
3079
+ } else if (!collected.label && intervention.label) {
3080
+ collected.label = intervention.label;
3081
+ }
3082
+ if (!collected.drivingEventSet.has(event.eventType)) {
3083
+ collected.drivingEventSet.add(event.eventType);
3084
+ collected.drivingEvents.push(event.eventType);
3085
+ }
3086
+ }
3087
+ }
3088
+ const interventionGaps = [];
3089
+ for (const intervention of interventions.values()) {
3090
+ const plannedEvents = intervention.drivingEvents.filter((code) => outcomes.has(code));
3091
+ const missingEvents = plannedEvents.flatMap((code) => {
3092
+ const outcome = outcomes.get(code);
3093
+ if (outcome?.status !== "skipped") return [];
3094
+ return [{ code, ...outcome.reason ? { reason: outcome.reason } : {} }];
3095
+ });
3096
+ if (missingEvents.length === 0) continue;
3097
+ interventionGaps.push({
3098
+ code: intervention.code,
3099
+ ...intervention.label ? { label: intervention.label } : {},
3100
+ blocked: missingEvents.length === plannedEvents.length ? "fully" : "partially",
3101
+ missingEvents
3102
+ });
3103
+ }
3104
+ interventionGaps.sort((a, b) => {
3105
+ if (a.blocked !== b.blocked) return a.blocked === "fully" ? -1 : 1;
3106
+ return b.missingEvents.length - a.missingEvents.length;
3107
+ });
3108
+ const otherSurface = input.manifest.universeSummary?.otherSurface ?? 0;
3109
+ const derived = input.manifest.universeSummary?.derived ?? 0;
3110
+ return {
3111
+ interventionGaps,
3112
+ otherSurface,
3113
+ derived,
3114
+ hasContent: interventionGaps.length > 0 || otherSurface > 0
3115
+ };
3116
+ }
3117
+ function renderGapReport(report, theme2) {
3118
+ const lines = [];
3119
+ for (const gap of report.interventionGaps) {
3120
+ const status = gap.blocked === "fully" ? "on hold" : "running below potential";
3121
+ lines.push(`\u25A0 ${theme2.bright(gap.label ?? gap.code)}${theme2.muted(` \u2014 ${status}`)}`);
3122
+ for (const event of gap.missingEvents) {
3123
+ const reason = event.reason ? ` \u2014 ${lowercaseFirst(event.reason)}` : "";
3124
+ lines.push(
3125
+ theme2.muted(" \xB7 ") + theme2.bright(event.code) + theme2.muted(` isn't flowing yet${reason}`)
3126
+ );
3127
+ }
3128
+ lines.push(
3129
+ theme2.muted(
3130
+ gap.blocked === "fully" ? " Ship the missing flow and this play switches on by itself \u2014 the strategy is already built for it." : " It works today, but with only part of its signal \u2014 every event above sharpens it."
3131
+ )
3132
+ );
3133
+ }
3134
+ if (report.otherSurface > 0) {
3135
+ const suffix = report.otherSurface === 1 ? "" : "s";
3136
+ lines.push(
3137
+ `\u25A0 ${report.otherSurface} more event${suffix} live on another surface (your backend or another app)`
3138
+ );
3139
+ lines.push(
3140
+ theme2.muted(
3141
+ " Run the wizard there and they join the same strategy \u2014 nothing to reconfigure."
3142
+ )
3143
+ );
3144
+ }
3145
+ if (report.derived > 0) {
3146
+ const suffix = report.derived === 1 ? "" : "s";
3147
+ lines.push(
3148
+ theme2.muted(
3149
+ `${report.derived} derived signal${suffix} (dormancy, anniversaries, expiries) are computed by Whisperr automatically once your raw events flow \u2014 nothing to wire.`
3150
+ )
3151
+ );
3152
+ }
3153
+ lines.push(
3154
+ theme2.muted(
3155
+ "All of this is capacity your strategy already includes. Each item you unlock compounds the rest."
3156
+ )
3157
+ );
3158
+ return lines.join("\n");
3159
+ }
3160
+ function trimmedReason(reason) {
3161
+ const trimmed = reason?.trim();
3162
+ return trimmed || void 0;
3163
+ }
3164
+ function lowercaseFirst(value) {
3165
+ return value.slice(0, 1).toLowerCase() + value.slice(1);
3166
+ }
2882
3167
 
2883
3168
  // src/ui/banner.ts
2884
3169
  function banner() {
@@ -2943,11 +3228,13 @@ Flutter is live today; ${theme.bright(
2943
3228
  p.cancel(theme.alert(err.message));
2944
3229
  return 1;
2945
3230
  }
3231
+ startSessionKeepalive(config, session);
2946
3232
  const fingerprint = await repoFingerprint(repoPath);
2947
3233
  const manifest = await withSpinner(
2948
3234
  "Loading your onboarding context",
2949
3235
  () => fetchManifest(config, session, chosen.playbook.target.id, fingerprint)
2950
3236
  );
3237
+ const approvedSuggestionsPromise = config.offline ? null : fetchSuggestions(config, session, ["approved"]);
2951
3238
  p.note(
2952
3239
  summarizeManifest(manifest),
2953
3240
  theme.signal(`Plan for ${manifest.appName ?? manifest.appId}`)
@@ -3071,9 +3358,7 @@ Flutter is live today; ${theme.bright(
3071
3358
  if (files.length) {
3072
3359
  p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
3073
3360
  }
3074
- if (outcome.summary.trim()) {
3075
- p.log.message(outcome.summary.trim());
3076
- }
3361
+ logAgentSummary(outcome.summary);
3077
3362
  if (!outcome.coreOk) {
3078
3363
  const reverted = await maybeRevert(
3079
3364
  repoPath,
@@ -3124,7 +3409,7 @@ Flutter is live today; ${theme.bright(
3124
3409
  if (repair.summary.trim()) {
3125
3410
  outcome.summary = [outcome.summary, `Repair:
3126
3411
  ${repair.summary}`].filter(Boolean).join("\n\n");
3127
- p.log.message(repair.summary.trim());
3412
+ logAgentSummary(repair.summary);
3128
3413
  }
3129
3414
  repairSpin.stop(theme.success("Repair pass finished"));
3130
3415
  } catch (err) {
@@ -3243,7 +3528,7 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3243
3528
  verified,
3244
3529
  cost_usd: outcome.costUsd,
3245
3530
  duration_ms: outcome.durationMs,
3246
- summary: outcome.summary.slice(0, 4e3),
3531
+ summary: scrubSummary(outcome.summary).slice(0, 4e3),
3247
3532
  events: reportEvents
3248
3533
  });
3249
3534
  if (!reportResult.ok) {
@@ -3253,10 +3538,62 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3253
3538
  )
3254
3539
  );
3255
3540
  }
3541
+ try {
3542
+ const approvedSuggestions = await approvedSuggestionsPromise;
3543
+ if (approvedSuggestions?.length) {
3544
+ const integratedEventCodes = new Set(
3545
+ reportEvents.filter((event) => event.status === "wired").map((event) => normalizeCode(event.event_type))
3546
+ );
3547
+ for (const event of manifest.events) {
3548
+ if (event.coverage?.some((entry) => entry.sameSurface && entry.status === "wired")) {
3549
+ integratedEventCodes.add(normalizeCode(event.eventType));
3550
+ }
3551
+ }
3552
+ const toMark = approvedSuggestions.filter(
3553
+ (suggestion) => suggestion.status === "approved" && (suggestion.kind === "intervention" || integratedEventCodes.has(normalizeCode(suggestion.code)))
3554
+ );
3555
+ const marked = (await Promise.all(
3556
+ toMark.map(
3557
+ (suggestion) => markSuggestionIntegrated(
3558
+ config,
3559
+ session,
3560
+ suggestion.id,
3561
+ chosen.playbook.target.id,
3562
+ fingerprint
3563
+ )
3564
+ )
3565
+ )).filter(Boolean).length;
3566
+ if (marked > 0) {
3567
+ p.log.info(
3568
+ theme.muted(
3569
+ `${marked} approved suggestion${marked === 1 ? "" : "s"} marked integrated.`
3570
+ )
3571
+ );
3572
+ }
3573
+ }
3574
+ } catch {
3575
+ }
3256
3576
  const eventLines = renderEventOutcomeLines(outcome.eventOutcomes, wiredMap);
3257
3577
  if (eventLines) {
3258
3578
  p.note(eventLines, "Events");
3259
3579
  }
3580
+ const eventOutcomeByType = new Map(
3581
+ outcome.eventOutcomes.map((event) => [event.event, event])
3582
+ );
3583
+ const gapReport = buildGapReport({
3584
+ manifest,
3585
+ events: reportEvents.map((event) => ({
3586
+ eventType: event.event_type,
3587
+ status: event.status,
3588
+ reason: eventOutcomeByType.get(event.event_type)?.reason
3589
+ }))
3590
+ });
3591
+ if (gapReport.hasContent) {
3592
+ p.note(
3593
+ renderGapReport(gapReport, theme),
3594
+ theme.signal("What you could be doing")
3595
+ );
3596
+ }
3260
3597
  const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
3261
3598
  const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
3262
3599
  const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
@@ -3299,7 +3636,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3299
3636
  }
3300
3637
  var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
3301
3638
  async function offerOpportunities(opts) {
3302
- const { opportunities, manifest, config, session } = opts;
3639
+ const { manifest, config, session } = opts;
3640
+ const openSuggestions = await fetchSuggestions(config, session, ["proposed", "approved"]);
3641
+ const openSuggestionKeys = new Set(
3642
+ openSuggestions.map(
3643
+ (suggestion) => `${suggestion.kind}:${normalizeCode(suggestion.code)}`
3644
+ )
3645
+ );
3646
+ const opportunities = {
3647
+ events: opts.opportunities.events.filter(
3648
+ (event) => !openSuggestionKeys.has(`event:${normalizeCode(event.code)}`)
3649
+ ),
3650
+ interventions: opts.opportunities.interventions.filter(
3651
+ (intervention) => !openSuggestionKeys.has(`intervention:${normalizeCode(intervention.code)}`)
3652
+ )
3653
+ };
3303
3654
  const total = opportunities.events.length + opportunities.interventions.length;
3304
3655
  if (total === 0) return NO_ADDITIONS;
3305
3656
  const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
@@ -3323,7 +3674,7 @@ async function offerOpportunities(opts) {
3323
3674
  return NO_ADDITIONS;
3324
3675
  }
3325
3676
  const selection = await p.multiselect({
3326
- message: "Add these to your Whisperr universe? (new interventions start paused)",
3677
+ message: "Send these to your dashboard for approval?",
3327
3678
  options: [
3328
3679
  ...opportunities.events.map((e) => ({
3329
3680
  value: `e:${e.code}`,
@@ -3336,8 +3687,8 @@ async function offerOpportunities(opts) {
3336
3687
  hint: i.description ?? i.rationale
3337
3688
  }))
3338
3689
  ],
3339
- // Opt-in per item: nothing is pre-selected, so adding to the universe is an
3340
- // explicit choice rather than an opt-out the user has to notice and undo.
3690
+ // Opt-in per item: nothing is pre-selected, so submitting a suggestion is
3691
+ // an explicit choice rather than an opt-out the user has to notice and undo.
3341
3692
  initialValues: [],
3342
3693
  required: false
3343
3694
  });
@@ -3370,6 +3721,39 @@ async function offerOpportunities(opts) {
3370
3721
  )
3371
3722
  }))
3372
3723
  };
3724
+ let stageResult;
3725
+ try {
3726
+ stageResult = await withSpinner(
3727
+ "Sending suggestions for approval",
3728
+ () => submitSuggestions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
3729
+ );
3730
+ } catch (err) {
3731
+ p.log.warn(
3732
+ theme.warn("Couldn't send the proposals") + theme.muted(` \u2014 ${err.message}. Nothing was submitted.`)
3733
+ );
3734
+ return NO_ADDITIONS;
3735
+ }
3736
+ if (stageResult) {
3737
+ const lines2 = stageResult.outcomes.map((outcome) => {
3738
+ const mark = outcome.status === "staged" ? theme.success("\u2713") : outcome.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3739
+ const reason = outcome.reason ?? (outcome.duplicateOf && outcome.duplicateOf !== outcome.code ? `already queued as ${outcome.duplicateOf}` : void 0);
3740
+ const detail = reason ? theme.muted(` (${reason})`) : "";
3741
+ return `${mark} ${outcome.kind} ${theme.bright(outcome.code)}${detail}`;
3742
+ });
3743
+ lines2.push(
3744
+ theme.muted(
3745
+ `${stageResult.staged} staged \xB7 ${stageResult.duplicates} already queued \xB7 ${stageResult.invalid} rejected`
3746
+ )
3747
+ );
3748
+ p.note(lines2.join("\n"), theme.signal("Suggestions submitted"));
3749
+ const where = stageResult.approvalsUrl ? `approve at ${theme.bright(stageResult.approvalsUrl)}` : "approve them in your Whisperr dashboard";
3750
+ p.log.info(
3751
+ theme.muted(
3752
+ `${stageResult.staged} proposal${stageResult.staged === 1 ? "" : "s"} sent \u2014 ${where}. The next wizard run wires whatever you approve.`
3753
+ )
3754
+ );
3755
+ return NO_ADDITIONS;
3756
+ }
3373
3757
  let result;
3374
3758
  try {
3375
3759
  result = await withSpinner(
@@ -3435,7 +3819,7 @@ async function offerOpportunities(opts) {
3435
3819
  });
3436
3820
  if (pass.ran) {
3437
3821
  spin.stop(theme.success("New events instrumented"));
3438
- if (pass.summary.trim()) p.log.message(pass.summary.trim());
3822
+ logAgentSummary(pass.summary);
3439
3823
  } else {
3440
3824
  spin.stop(
3441
3825
  theme.warn("Skipped instrumenting the new events") + theme.muted(
@@ -3515,10 +3899,25 @@ async function withBrowserAuth(config) {
3515
3899
  p.log.step(
3516
3900
  theme.bright("Authenticate") + theme.muted(" \u2014 opening your browser to approve this device\u2026")
3517
3901
  );
3902
+ const auth = await startDeviceAuth(config);
3903
+ p.note(
3904
+ [
3905
+ theme.bright(`Your code: ${auth.userCode}`),
3906
+ `${theme.muted("Approval URL:")} ${auth.verificationUrl}`,
3907
+ theme.muted(
3908
+ "We tried to open your browser. If it didn't open, visit that link on any device and enter the code."
3909
+ )
3910
+ ].join("\n"),
3911
+ theme.signal("Approve this device")
3912
+ );
3913
+ try {
3914
+ await open2(auth.verificationUrlComplete ?? auth.verificationUrl);
3915
+ } catch {
3916
+ }
3518
3917
  const spin = p.spinner();
3519
3918
  spin.start("Waiting for you to approve in the browser");
3520
3919
  try {
3521
- const session = await authenticate(config);
3920
+ const session = await auth.poll();
3522
3921
  spin.stop(theme.success("Authenticated \u2713"));
3523
3922
  return session;
3524
3923
  } catch (err) {
@@ -3588,6 +3987,10 @@ function renderEventOutcomeLines(outcomes, wiredMap) {
3588
3987
  if (remainder > 0) lines.push(theme.muted(`\u2026 ${remainder} more events`));
3589
3988
  return lines.join("\n");
3590
3989
  }
3990
+ function logAgentSummary(summary) {
3991
+ const cleaned = scrubSummary(summary).trim();
3992
+ if (cleaned) p.log.message(cleaned);
3993
+ }
3591
3994
  function shortPhaseLabel(phase) {
3592
3995
  switch (phase) {
3593
3996
  case "Mapping your codebase":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whisperr/wizard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Whisperr Wizard — one command to integrate the Whisperr SDK into your app. Authenticates with your onboarded account and uses an AI coding agent to wire up identify() and your business events automatically.",
5
5
  "repository": {
6
6
  "type": "git",