@whisperr/wizard 0.1.15 → 0.2.1

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 +855 -161
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { readFileSync, realpathSync } from "fs";
5
- import { dirname, join as join3 } from "path";
5
+ import { dirname as dirname2, join as join4 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // src/cli.ts
@@ -647,51 +647,135 @@ var phpPlaybook = {
647
647
  // src/core/playbooks/react-native.ts
648
648
  var target7 = {
649
649
  id: "react-native",
650
- displayName: "React Native",
650
+ displayName: "React Native / Expo",
651
651
  language: "typescript",
652
- availability: "planned"
652
+ availability: "available"
653
653
  };
654
654
  async function detect7(ctx) {
655
655
  if (!await ctx.exists("package.json")) return null;
656
656
  const pkgJson = await ctx.read("package.json");
657
- let match = false;
657
+ let hasRN = false;
658
+ let hasExpo = false;
658
659
  try {
659
660
  const pkg = JSON.parse(pkgJson);
660
661
  const all = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
661
- match = "react-native" in all || "expo" in all;
662
+ hasRN = "react-native" in all;
663
+ hasExpo = "expo" in all;
662
664
  } catch {
663
- match = false;
664
665
  }
665
- if (!match) return null;
666
- const evidence = ["react-native / expo dependency"];
666
+ if (!hasRN && !hasExpo) return null;
667
+ const evidence = [];
668
+ if (hasExpo) evidence.push("expo dependency");
669
+ if (hasRN) evidence.push("react-native dependency");
667
670
  let confidence = 0.85;
668
671
  if (await ctx.exists("app.json")) {
669
672
  evidence.push("app.json");
670
673
  confidence += 0.1;
674
+ } else if (await ctx.exists("app.config.js") || await ctx.exists("app.config.ts")) {
675
+ evidence.push("app.config");
676
+ confidence += 0.1;
671
677
  }
672
678
  return { target: target7, confidence: Math.min(confidence, 1), evidence };
673
679
  }
674
680
  var systemPrompt7 = `
675
681
  ## SDK: Whisperr for React Native \u2014 package \`@whisperr/react-native\`
676
682
 
677
- 1) Install \`@whisperr/react-native\` with the repo's package manager. If it has
678
- native modules, run pod install for iOS (mention it; don't attempt to build).
683
+ Pure TypeScript, ZERO native code and zero dependencies of its own: it works in
684
+ Expo Go, bare React Native, and dev clients with no pod install, no config
685
+ plugin, no prebuild. Do not add or modify anything under ios/ or android/ for
686
+ the SDK itself.
687
+
688
+ 0) Expo or bare? Decide first \u2014 it changes storage install + key handling.
689
+ Expo = \`expo\` in package.json dependencies (usually with app.json /
690
+ app.config.* containing an "expo" key).
691
+
692
+ 1) Install with the repo's package manager (pick by lockfile: pnpm-lock.yaml \u2192
693
+ pnpm, yarn.lock \u2192 yarn, bun.lockb \u2192 bun, else npm):
694
+ <pm> add @whisperr/react-native
695
+
696
+ 2) Durable storage \u2014 IMPORTANT. The SDK's offline queue is MEMORY-ONLY unless
697
+ you inject an AsyncStorage-compatible adapter (\`getItem\`/\`setItem\`/
698
+ \`removeItem\`); the SDK never imports native modules itself. In priority
699
+ order:
700
+ - \`@react-native-async-storage/async-storage\` already a dependency \u2192 import
701
+ it and pass as \`storage\`.
702
+ - The app already uses \`react-native-mmkv\` \u2192 reuse its existing instance
703
+ with a 3-line adapter (do NOT add a new storage dep):
704
+ const storage = {
705
+ getItem: (k: string) => mmkv.getString(k) ?? null,
706
+ setItem: (k: string, v: string) => { mmkv.set(k, v); },
707
+ removeItem: (k: string) => { mmkv.delete(k); },
708
+ };
709
+ - Neither \u2192 add async-storage. Expo: run \`npx expo install
710
+ @react-native-async-storage/async-storage\` via bash (installs the
711
+ SDK-matched version; works in Expo Go \u2014 it ships in the Go client). Bare
712
+ RN: \`<pm> add @react-native-async-storage/async-storage\` and note
713
+ "run npx pod-install before the next iOS build" as a follow-up (it IS a
714
+ native module; do not run pod install yourself).
715
+ Never silently ship without storage \u2014 if you genuinely can't add it, say so
716
+ in the summary.
717
+
718
+ 3) Initialize ONCE via a module singleton, e.g. src/whisperr.ts (match the
719
+ repo's source layout \u2014 src/, lib/, app/lib/\u2026):
720
+ import AsyncStorage from '@react-native-async-storage/async-storage';
721
+ import { Whisperr } from '@whisperr/react-native';
679
722
 
680
- 2) Initialize once at the app root (App.tsx / app/_layout.tsx for Expo Router):
681
- import { Whisperr } from '@whisperr/react-native';
682
- Whisperr.init({ apiKey: <key from manifest/env>, baseUrl: <manifest baseUrl> });
683
-
684
- 3) identify() after auth resolves / on session restore; reset() on logout.
685
- For channels: email/phone where known, and the push token (expo-notifications
686
- or @react-native-firebase/messaging) if the app has push set up.
687
-
688
- 4) track() in event handlers / business logic, never in render. event_type
689
- verbatim from the manifest.
723
+ export const whisperr = Whisperr.init({
724
+ apiKey: process.env.EXPO_PUBLIC_WHISPERR_KEY!,
725
+ storage: AsyncStorage,
726
+ baseUrl: '<INGESTION_BASE_URL from manifest>', // omit if it's the SDK default
727
+ });
728
+ Import the module for its side effect from the app entry: Expo Router \u2192
729
+ app/_layout.tsx (root layout); bare / React Navigation \u2192 App.tsx (or
730
+ index.js). Prefer the singleton over <WhisperrProvider> \u2014 auth listeners,
731
+ API clients, and non-component code can import it directly. If the codebase
732
+ is strongly hook-oriented you may ADDITIONALLY wrap the root with
733
+ <WhisperrProvider client={whisperr}> and use useWhisperr() in components \u2014
734
+ but never create a second client.
735
+ Whisperr.init() is an idempotent singleton \u2014 safe under Fast Refresh and
736
+ React StrictMode.
737
+
738
+ Key handling (the key is a publishable ingestion key \u2014 shipping it in the
739
+ bundle is expected):
740
+ - Expo SDK 49+: put EXPO_PUBLIC_WHISPERR_KEY=<key> in .env (create or
741
+ append) and mirror it in .env.example; reference via
742
+ process.env.EXPO_PUBLIC_WHISPERR_KEY.
743
+ - Bare RN with react-native-config: add WHISPERR_KEY to .env and read
744
+ Config.WHISPERR_KEY.
745
+ - Otherwise: a small constants file with the literal key is acceptable.
746
+
747
+ 4) identify() right after the end-user is known \u2014 on login/signup success AND
748
+ on session restore at app startup:
749
+ whisperr.identify(user.id, {
750
+ traits: { /* manifest traits sourced from the user object */ },
751
+ email: user.email, // shortcut \u2192 opted-in email channel
752
+ phone: user.phone, // shortcut \u2192 sms channel
753
+ pushToken: expoPushToken, // shortcut \u2192 push channel, if the app
754
+ // registers for push (expo-notifications
755
+ // or @react-native-firebase/messaging)
756
+ });
757
+ Call whisperr.reset() on logout (already-queued events keep their user).
758
+ ANONYMOUS EVENTS ARE FINE: track() before identify() buffers on-device and
759
+ attributes to the user retroactively on identify() \u2014 do NOT gate track()
760
+ calls behind auth state, and do NOT skip pre-login manifest events.
761
+
762
+ 5) track() in event handlers / business logic (onPress, mutation onSuccess,
763
+ store/saga/thunk actions) \u2014 never in render:
764
+ whisperr.track('event_type_from_manifest', { /* properties in scope */ });
765
+ Synchronous fire-and-forget: it returns void \u2014 do NOT await it. Batching,
766
+ retries, background flush are all internal. event_type verbatim snake_case
767
+ from the manifest \u2014 the SDK client-side drops invalid names.
690
768
 
691
- Notes:
692
- - Persisted offline queue is built in (AsyncStorage); fire-and-forget is fine.
693
- - Prefer an env/config mechanism (react-native-config, app.config) for the key
694
- if one exists; otherwise a constants file is acceptable.
769
+ Notes / gotchas:
770
+ - Every call is sync void except flush(); the only sensible manual flush is
771
+ \`await whisperr.flush()\` right before something destroys the process.
772
+ - The SDK auto-flushes on an interval, at 20 queued events, and when the app
773
+ backgrounds. Don't sprinkle flush() calls.
774
+ - whisperr.screen(name) records a screen_viewed event \u2014 wire it to the
775
+ navigation container's state-change callback ONLY if the manifest includes a
776
+ screen/page-view style event; otherwise leave navigation alone.
777
+ - Works on the New Architecture and in Expo Go by construction (no native
778
+ code). Never suggest expo prebuild / dev-client for this SDK.
695
779
  `.trim();
696
780
  var reactNativePlaybook = {
697
781
  target: target7,
@@ -703,9 +787,9 @@ var reactNativePlaybook = {
703
787
  // src/core/playbooks/swift.ts
704
788
  var target8 = {
705
789
  id: "swift",
706
- displayName: "Swift (iOS)",
790
+ displayName: "Swift (iOS / Apple)",
707
791
  language: "swift",
708
- availability: "planned"
792
+ availability: "available"
709
793
  };
710
794
  async function detect8(ctx) {
711
795
  const evidence = [];
@@ -727,40 +811,115 @@ async function detect8(ctx) {
727
811
  evidence.push("Podfile");
728
812
  confidence += 0.2;
729
813
  }
814
+ if (await ctx.exists("Project.swift")) {
815
+ evidence.push("Project.swift (Tuist)");
816
+ confidence += 0.4;
817
+ }
818
+ if (await ctx.exists("project.yml")) {
819
+ evidence.push("project.yml (XcodeGen)");
820
+ confidence += 0.3;
821
+ }
730
822
  if (confidence === 0) return null;
731
823
  return { target: target8, confidence: Math.min(confidence, 1), evidence };
732
824
  }
733
825
  var systemPrompt8 = `
734
- ## SDK: Whisperr for Swift (iOS) \u2014 Swift Package \`Whisperr\`
735
-
736
- 1) Add the Swift Package dependency (github.com/WhisperrAI/whisperr-swift). If the
737
- project uses Package.swift, add it there; if it's an .xcodeproj/.xcworkspace,
738
- you cannot edit the project graph reliably from the shell \u2014 instead leave a
739
- clearly commented setup note and the exact import + init code, and report it
740
- as a manual follow-up.
741
-
742
- 2) Initialize once at launch in the App struct (SwiftUI) or
743
- application(_:didFinishLaunchingWithOptions:) (UIKit):
744
- import Whisperr
745
- Whisperr.initialize(apiKey: "<key>", baseUrl: "<manifest baseUrl>")
746
-
747
- 3) identify() after the user is known / on session restore:
748
- Whisperr.shared.identify("<external user id>", traits: [...], email: ..., phone: ...)
749
- reset() on logout. Push channel = APNs device token if the app registers for
750
- remote notifications.
826
+ ## SDK: Whisperr for Swift \u2014 Swift Package \`Whisperr\` (github.com/WhisperrAI/whisperr-swift)
827
+
828
+ Platforms: iOS 13+, macOS 12+, tvOS 13+, watchOS 6+. The client is a Swift
829
+ ACTOR: every call is \`await\`ed, and identify()/track() are throwing. Getting
830
+ the concurrency and JSONValue details below right is what makes the diff
831
+ compile first try.
832
+
833
+ 1) Dependency \u2014 pick by how the project is defined:
834
+ - Package.swift: add to the package's dependencies
835
+ .package(url: "https://github.com/WhisperrAI/whisperr-swift.git", from: "0.1.0")
836
+ and \`"Whisperr"\` to the app target's dependencies array.
837
+ - Tuist (Project.swift) or XcodeGen (project.yml): these manifests are plain
838
+ text \u2014 add the SPM package + product there following the file's existing
839
+ style.
840
+ - Raw .xcodeproj / .xcworkspace only: do NOT hand-edit project.pbxproj to
841
+ add a package reference (fragile, breaks the project on a bad GUID).
842
+ Write ALL the integration code anyway (imports, init, identify, track),
843
+ add a short WHISPERR_SETUP.md with the one manual step \u2014 Xcode \u2192 File \u2192
844
+ Add Package Dependencies\u2026 \u2192 \`https://github.com/WhisperrAI/whisperr-swift\`
845
+ \u2192 add "Whisperr" to the app target \u2014 and report that as the single manual
846
+ follow-up in your summary.
847
+
848
+ 2) Initialize once at launch.
849
+ SwiftUI:
850
+ import Whisperr
851
+
852
+ @main
853
+ struct MyApp: App {
854
+ init() {
855
+ Task { await Whisperr.initialize(apiKey: "<INGESTION_API_KEY>") }
856
+ }
857
+ // ... existing body
858
+ }
859
+ UIKit: same Task { } inside application(_:didFinishLaunchingOptions:).
860
+ Only pass a base URL if the manifest's differs from the SDK default:
861
+ await Whisperr.initialize(apiKey: "...", baseURL: URL(string: "<INGESTION_BASE_URL>")!)
862
+ Note baseURL takes a URL, not a String. Prefer reading the key from
863
+ Info.plist / an .xcconfig if the project already uses that pattern;
864
+ otherwise a literal is acceptable (publishable ingestion key).
865
+
866
+ 3) identify() right after the end-user is known \u2014 login/signup success AND
867
+ session restore. All calls go through the async optional singleton
868
+ \`await Whisperr.shared\`; in synchronous contexts wrap in Task { }:
869
+ Task {
870
+ try? await Whisperr.shared?.identify(
871
+ user.id, // stable external id \u2014 never a device id
872
+ traits: ["plan": .string(user.plan)],
873
+ email: user.email, // shortcut \u2192 opted-in email channel
874
+ phone: user.phone, // shortcut \u2192 sms channel
875
+ pushToken: apnsTokenHex // if the app registers for remote notifications
876
+ )
877
+ }
878
+ Logout:
879
+ Task { await Whisperr.shared?.reset() } // flushes, then clears the user
880
+ For explicit channel consent/verification use WhisperrChannel:
881
+ channels: [.email("a@b.com", verified: true), .sms("+1555\u2026", optedIn: false)]
882
+
883
+ 4) track() in action handlers / view-model methods \u2014 never in a View body:
884
+ Task {
885
+ try? await Whisperr.shared?.track(
886
+ "event_type_from_manifest",
887
+ properties: ["amount_cents": .number(Double(amountCents)),
888
+ "plan": .string(plan)]
889
+ )
890
+ }
891
+ Awaiting only ENQUEUES (delivery batches in the background) \u2014 \`try? await\`
892
+ inside Task { } is the correct fire-and-forget in UI code. In an already-
893
+ async throwing context, plain \`try await\` is fine. event_type verbatim
894
+ snake_case from the manifest.
895
+
896
+ CRITICAL \u2014 JSONValue typing for properties/traits:
897
+ Values are \`JSONValue\`, not \`Any\`. LITERALS convert automatically \u2014
898
+ \`["plan": "pro", "amount": 42, "active": true]\` compiles as-is \u2014 but
899
+ VARIABLES must be wrapped: \`.string(user.plan)\`, \`.number(Double(count))\`,
900
+ \`.bool(flag)\`, \`.array(...)\`, \`.object(...)\`. Mixing literals and wrapped
901
+ variables in one dictionary is fine. Forgetting the wrapper is the #1
902
+ compile error \u2014 check every non-literal value you pass.
903
+
904
+ CRITICAL \u2014 no anonymous buffering in this SDK:
905
+ track() THROWS WhisperrClientError.missingUserID until identify() has run in
906
+ this process (or when you pass \`userID:\` explicitly). Session restore must
907
+ therefore call identify() early at startup. Do not place track() on
908
+ pre-auth surfaces (onboarding, login screen) unless you pass
909
+ \`userID: "<id>"\` \u2014 if a manifest event genuinely happens before the user
910
+ exists, skip it and note it in the summary.
751
911
 
752
- 4) track() in action handlers / view-model methods, not in view body:
753
- Whisperr.shared.track("event_type_from_manifest", properties: [ ... ])
754
- event_type verbatim (snake_case) from the manifest.
755
-
756
- Notes:
757
- - Prefer reading the key from Info.plist / an xcconfig if the project uses one.
758
- - Do not attempt to run xcodebuild; verification is the human's step here.
912
+ Notes / gotchas:
913
+ - \`Whisperr.shared\` is \`async\` and optional \u2014 the call shape is always
914
+ \`await Whisperr.shared?.method(...)\`. Never store it in a non-async global.
915
+ - The queue persists via UserDefaults and survives restarts; retries, backoff,
916
+ and auth-pause semantics are built in. No manual flush() needed.
917
+ - Do not run xcodebuild, swift build, or tests \u2014 the human verifies in Xcode.
759
918
  `.trim();
760
919
  var swiftPlaybook = {
761
920
  target: target8,
762
921
  detect: detect8,
763
- packageRef: "Whisperr (Swift Package)",
922
+ packageRef: "whisperr-swift (Swift Package)",
764
923
  systemPrompt: systemPrompt8
765
924
  };
766
925
 
@@ -989,6 +1148,341 @@ function mockManifest(appId, config) {
989
1148
  // src/core/agent.ts
990
1149
  import { query } from "@anthropic-ai/claude-agent-sdk";
991
1150
 
1151
+ // src/core/opportunities.ts
1152
+ import { readFile as readFile3, rm as rm2 } from "fs/promises";
1153
+ import { join as join3 } from "path";
1154
+
1155
+ // src/core/git.ts
1156
+ import { spawn } from "child_process";
1157
+ import { createHash } from "crypto";
1158
+ import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
1159
+ import { basename, dirname, join as join2 } from "path";
1160
+ async function takeCheckpoint(repoPath) {
1161
+ const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1162
+ if (!isRepo) return { isRepo: false };
1163
+ const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1164
+ const head = await run(repoPath, ["rev-parse", "HEAD"]);
1165
+ return {
1166
+ isRepo: true,
1167
+ baseRef: head.ok ? head.stdout.trim() : void 0,
1168
+ branch: branch || void 0
1169
+ };
1170
+ }
1171
+ async function changedFiles(repoPath, checkpoint) {
1172
+ if (!checkpoint.isRepo) return [];
1173
+ const tracked = await run(repoPath, ["diff", "--name-only"]);
1174
+ const untracked = await run(repoPath, [
1175
+ "ls-files",
1176
+ "--others",
1177
+ "--exclude-standard"
1178
+ ]);
1179
+ const set = /* @__PURE__ */ new Set();
1180
+ for (const f of `${tracked.stdout}
1181
+ ${untracked.stdout}`.split("\n")) {
1182
+ if (f.trim()) set.add(f.trim());
1183
+ }
1184
+ return [...set];
1185
+ }
1186
+ function revertHint(checkpoint) {
1187
+ if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1188
+ return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1189
+ }
1190
+ async function wasTrackedAtCheckpoint(repoPath, checkpoint, file) {
1191
+ if (!checkpoint.isRepo) return false;
1192
+ if (checkpoint.baseRef) {
1193
+ const res2 = await run(repoPath, [
1194
+ "ls-tree",
1195
+ "--name-only",
1196
+ checkpoint.baseRef,
1197
+ "--",
1198
+ file
1199
+ ]);
1200
+ return res2.ok && res2.stdout.trim() !== "";
1201
+ }
1202
+ const res = await run(repoPath, ["ls-files", "--", file]);
1203
+ return res.ok && res.stdout.trim() !== "";
1204
+ }
1205
+ async function isWorkingTreeClean(repoPath) {
1206
+ const status = await run(repoPath, ["status", "--porcelain"]);
1207
+ return status.ok && status.stdout.trim() === "";
1208
+ }
1209
+ async function revertToCheckpoint(repoPath, checkpoint) {
1210
+ if (!checkpoint.isRepo) return false;
1211
+ if (checkpoint.baseRef) {
1212
+ const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
1213
+ const clean2 = await run(repoPath, ["clean", "-fd"]);
1214
+ return reset.ok && clean2.ok;
1215
+ }
1216
+ const restore = await run(repoPath, ["restore", "."]);
1217
+ const clean = await run(repoPath, ["clean", "-fd"]);
1218
+ return restore.ok && clean.ok;
1219
+ }
1220
+ async function snapshotChanges(repoPath, checkpoint) {
1221
+ const snapshot = /* @__PURE__ */ new Map();
1222
+ for (const file of await changedFiles(repoPath, checkpoint)) {
1223
+ try {
1224
+ snapshot.set(file, await readFile2(join2(repoPath, file)));
1225
+ } catch {
1226
+ snapshot.set(file, null);
1227
+ }
1228
+ }
1229
+ return snapshot;
1230
+ }
1231
+ function snapshotsEqual(a, b) {
1232
+ if (a.size !== b.size) return false;
1233
+ for (const [file, content] of a) {
1234
+ const other = b.get(file);
1235
+ if (other === void 0) return false;
1236
+ if (content === null || other === null) {
1237
+ if (content !== other) return false;
1238
+ } else if (!content.equals(other)) {
1239
+ return false;
1240
+ }
1241
+ }
1242
+ return true;
1243
+ }
1244
+ async function restoreToSnapshot(repoPath, checkpoint, snapshot) {
1245
+ try {
1246
+ const current = await changedFiles(repoPath, checkpoint);
1247
+ for (const file of /* @__PURE__ */ new Set([...current, ...snapshot.keys()])) {
1248
+ const recorded = snapshot.get(file);
1249
+ const path = join2(repoPath, file);
1250
+ if (recorded === void 0) {
1251
+ if (checkpoint.baseRef) {
1252
+ const co = await run(repoPath, ["checkout", checkpoint.baseRef, "--", file]);
1253
+ if (co.ok) continue;
1254
+ }
1255
+ await rm(path, { force: true });
1256
+ } else if (recorded === null) {
1257
+ await rm(path, { force: true });
1258
+ } else {
1259
+ await mkdir(dirname(path), { recursive: true });
1260
+ await writeFile(path, recorded);
1261
+ }
1262
+ }
1263
+ return true;
1264
+ } catch {
1265
+ return false;
1266
+ }
1267
+ }
1268
+ async function repoFingerprint(repoPath) {
1269
+ const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1270
+ const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
1271
+ return createHash("sha256").update(seed).digest("hex").slice(0, 16);
1272
+ }
1273
+ function normalizeRemote(url) {
1274
+ return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1275
+ }
1276
+ async function scanWiredEvents(repoPath, files, eventTypes) {
1277
+ const wired = /* @__PURE__ */ new Map();
1278
+ const patterns = eventTypes.map((e) => ({
1279
+ eventType: e,
1280
+ re: new RegExp(
1281
+ `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
1282
+ )
1283
+ }));
1284
+ for (const file of files) {
1285
+ let content = "";
1286
+ try {
1287
+ content = await readFile2(join2(repoPath, file), "utf8");
1288
+ } catch {
1289
+ continue;
1290
+ }
1291
+ for (const { eventType, re } of patterns) {
1292
+ if (!wired.has(eventType) && re.test(content)) {
1293
+ wired.set(eventType, file);
1294
+ }
1295
+ }
1296
+ }
1297
+ return wired;
1298
+ }
1299
+ function escapeRegExp(s) {
1300
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1301
+ }
1302
+ function run(cwd, args) {
1303
+ return new Promise((resolve2) => {
1304
+ const child = spawn("git", args, { cwd });
1305
+ let stdout = "";
1306
+ let stderr = "";
1307
+ child.stdout.on("data", (d) => stdout += d.toString());
1308
+ child.stderr.on("data", (d) => stderr += d.toString());
1309
+ child.on("close", (code) => resolve2({ ok: code === 0, stdout, stderr }));
1310
+ child.on("error", () => resolve2({ ok: false, stdout, stderr }));
1311
+ });
1312
+ }
1313
+
1314
+ // src/core/opportunities.ts
1315
+ var OPPORTUNITIES_FILE = "whisperr-opportunities.json";
1316
+ function normalizeCode(value) {
1317
+ return value.trim().toLowerCase().replace(/[ \-./]/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
1318
+ }
1319
+ async function collectOpportunities(repoPath, manifest, checkpoint) {
1320
+ const path = join3(repoPath, OPPORTUNITIES_FILE);
1321
+ if (await wasTrackedAtCheckpoint(repoPath, checkpoint, OPPORTUNITIES_FILE)) {
1322
+ return { events: [], interventions: [] };
1323
+ }
1324
+ let rawText;
1325
+ try {
1326
+ rawText = await readFile3(path, "utf8");
1327
+ } catch {
1328
+ return { events: [], interventions: [] };
1329
+ }
1330
+ await rm2(path, { force: true }).catch(() => {
1331
+ });
1332
+ let raw;
1333
+ try {
1334
+ raw = JSON.parse(rawText);
1335
+ } catch {
1336
+ return { events: [], interventions: [] };
1337
+ }
1338
+ return sanitizeOpportunities(raw, manifest);
1339
+ }
1340
+ function sanitizeOpportunities(raw, manifest) {
1341
+ const out = { events: [], interventions: [] };
1342
+ if (typeof raw !== "object" || raw === null) return out;
1343
+ const doc = raw;
1344
+ const knownEvents = new Set(manifest.events.map((e) => normalizeCode(e.eventType)));
1345
+ const knownInterventions = new Set(
1346
+ manifest.events.flatMap((e) => e.interventions ?? []).map((i) => normalizeCode(i.code))
1347
+ );
1348
+ const seenEvents = /* @__PURE__ */ new Set();
1349
+ for (const entry of asArray(doc.events)) {
1350
+ const ev = coerceEvent(entry);
1351
+ if (!ev) continue;
1352
+ if (knownEvents.has(ev.code) || seenEvents.has(ev.code)) continue;
1353
+ seenEvents.add(ev.code);
1354
+ out.events.push(ev);
1355
+ }
1356
+ const seenInterventions = /* @__PURE__ */ new Set();
1357
+ for (const entry of asArray(doc.interventions)) {
1358
+ const iv = coerceIntervention(entry);
1359
+ if (!iv) continue;
1360
+ if (knownInterventions.has(iv.code) || seenInterventions.has(iv.code)) continue;
1361
+ seenInterventions.add(iv.code);
1362
+ out.interventions.push(iv);
1363
+ }
1364
+ return out;
1365
+ }
1366
+ var SUBMIT_TIMEOUT_MS = 3e4;
1367
+ async function submitAdditions(config, session, target9, repoFingerprint2, opportunities) {
1368
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/additions`, {
1369
+ method: "POST",
1370
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1371
+ headers: {
1372
+ "Content-Type": "application/json",
1373
+ Authorization: `Bearer ${session.token}`
1374
+ },
1375
+ body: JSON.stringify({
1376
+ target: target9,
1377
+ repoFingerprint: repoFingerprint2,
1378
+ events: opportunities.events,
1379
+ interventions: opportunities.interventions
1380
+ })
1381
+ });
1382
+ if (!res.ok) {
1383
+ const body = await res.text().catch(() => "");
1384
+ throw new Error(
1385
+ `Submitting universe additions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1386
+ );
1387
+ }
1388
+ return await res.json();
1389
+ }
1390
+ function asArray(value) {
1391
+ return Array.isArray(value) ? value : [];
1392
+ }
1393
+ function asString(value) {
1394
+ if (typeof value !== "string") return void 0;
1395
+ const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
1396
+ return cleaned ? cleaned : void 0;
1397
+ }
1398
+ function asConfidence(value) {
1399
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1400
+ return Math.min(1, Math.max(0, value));
1401
+ }
1402
+ function asWeight(value) {
1403
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1404
+ if (value < 0 || value > 1) return void 0;
1405
+ return value;
1406
+ }
1407
+ function coerceEvent(entry) {
1408
+ if (typeof entry !== "object" || entry === null) return null;
1409
+ const e = entry;
1410
+ const code = normalizeCode(asString(e.code) ?? "");
1411
+ if (!code) return null;
1412
+ const properties = [];
1413
+ for (const prop of asArray(e.properties)) {
1414
+ if (typeof prop === "string") {
1415
+ if (prop.trim()) properties.push({ name: prop.trim() });
1416
+ continue;
1417
+ }
1418
+ if (typeof prop !== "object" || prop === null) continue;
1419
+ const pr = prop;
1420
+ const name = asString(pr.name);
1421
+ if (!name) continue;
1422
+ const desc = asString(pr.description);
1423
+ properties.push({
1424
+ name,
1425
+ ...desc ? { description: desc } : {},
1426
+ ...pr.required === true ? { required: true } : {}
1427
+ });
1428
+ }
1429
+ const links = [];
1430
+ for (const link of asArray(e.links)) {
1431
+ if (typeof link !== "object" || link === null) continue;
1432
+ const l = link;
1433
+ const interventionCode = normalizeCode(asString(l.interventionCode) ?? "");
1434
+ if (!interventionCode) continue;
1435
+ const weight = asWeight(l.weight);
1436
+ links.push({ interventionCode, ...weight !== void 0 ? { weight } : {} });
1437
+ }
1438
+ const side = normalizeSide(asString(e.side));
1439
+ return {
1440
+ code,
1441
+ label: asString(e.label),
1442
+ description: asString(e.description),
1443
+ ...side ? { side } : {},
1444
+ rationale: asString(e.rationale),
1445
+ confidence: asConfidence(e.confidence),
1446
+ ...properties.length ? { properties } : {},
1447
+ ...links.length ? { links } : {}
1448
+ };
1449
+ }
1450
+ function coerceIntervention(entry) {
1451
+ if (typeof entry !== "object" || entry === null) return null;
1452
+ const i = entry;
1453
+ const code = normalizeCode(asString(i.code) ?? "");
1454
+ if (!code) return null;
1455
+ const links = [];
1456
+ for (const link of asArray(i.links)) {
1457
+ if (typeof link !== "object" || link === null) continue;
1458
+ const l = link;
1459
+ const eventCode = normalizeCode(asString(l.eventCode) ?? "");
1460
+ if (!eventCode) continue;
1461
+ const weight = asWeight(l.weight);
1462
+ links.push({ eventCode, ...weight !== void 0 ? { weight } : {} });
1463
+ }
1464
+ return {
1465
+ code,
1466
+ label: asString(i.label),
1467
+ description: asString(i.description),
1468
+ rationale: asString(i.rationale),
1469
+ confidence: asConfidence(i.confidence),
1470
+ ...links.length ? { links } : {}
1471
+ };
1472
+ }
1473
+ function normalizeSide(value) {
1474
+ switch (value?.toLowerCase()) {
1475
+ case "frontend":
1476
+ return "frontend";
1477
+ case "backend":
1478
+ return "backend";
1479
+ case "either":
1480
+ return "either";
1481
+ default:
1482
+ return void 0;
1483
+ }
1484
+ }
1485
+
992
1486
  // src/core/playbooks/shared-prompt.ts
993
1487
  var BASE_WIZARD_PROMPT = `
994
1488
  You are the Whisperr Wizard \u2014 an expert integration agent running INSIDE a
@@ -1165,6 +1659,46 @@ function renderEventsBrief(m) {
1165
1659
  }
1166
1660
  return lines.join("\n");
1167
1661
  }
1662
+ function renderOpportunitiesBrief(m, opportunitiesFile) {
1663
+ const eventCodes = m.events.map((e) => e.eventType);
1664
+ const interventionCodes = [
1665
+ ...new Set(m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code))
1666
+ ];
1667
+ return [
1668
+ "UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
1669
+ "While auditing you read this app's real lifecycle. If you found churn-",
1670
+ "relevant moments the plan does NOT cover \u2014 e.g. a recurring-payment or",
1671
+ "cancellation path with no event, a support/refund flow worth a retention",
1672
+ "play \u2014 record them as PROPOSALS. Do NOT add track() calls for them.",
1673
+ `Write a single JSON file at the repo root named ${opportunitiesFile}:`,
1674
+ "",
1675
+ "{",
1676
+ ' "events": [{',
1677
+ ' "code": "snake_case_event", "label": "...", "description": "...",',
1678
+ ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1679
+ ' "confidence": 0.0-1.0,',
1680
+ ' "properties": [{"name": "...", "description": "...", "required": false}],',
1681
+ ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1682
+ " }],",
1683
+ ' "interventions": [{',
1684
+ ' "code": "snake_case_strategy", "label": "...", "description": "...",',
1685
+ ' "rationale": "...", "confidence": 0.0-1.0,',
1686
+ ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1687
+ " }]",
1688
+ "}",
1689
+ "",
1690
+ "Rules:",
1691
+ `- The plan already covers these events: ${eventCodes.join(", ") || "(none)"}.`,
1692
+ ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1693
+ " Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
1694
+ "- Every proposal needs concrete code evidence in its rationale (file/flow),",
1695
+ " not speculation. 0-3 events and 0-2 interventions is the normal range;",
1696
+ " an empty file or no file at all is a perfectly good outcome.",
1697
+ "- Link each proposed event to the intervention(s) it should feed (existing",
1698
+ " codes or ones you propose in the same file).",
1699
+ "- This file is metadata for the wizard, not app code \u2014 write it and move on."
1700
+ ].join("\n");
1701
+ }
1168
1702
  function coverageNote(coverage) {
1169
1703
  if (!coverage?.length) return "";
1170
1704
  const here = coverage.find((c) => c.sameSurface && c.status === "wired");
@@ -1289,8 +1823,12 @@ ${events.summary}`);
1289
1823
  "4. Coverage \u2014 every gateway/callback path that should emit an event does;",
1290
1824
  " no recurring or secondary path left as a dead zone.",
1291
1825
  "Change ONLY what is genuinely wrong or incomplete \u2014 do not churn correct",
1292
- "calls or re-explore the whole repo. Be surgical. End with a one-line,",
1293
- "plain-text note of what you corrected, or 'No corrections needed.'."
1826
+ "calls or re-explore the whole repo. Be surgical.",
1827
+ "",
1828
+ renderOpportunitiesBrief(manifest, OPPORTUNITIES_FILE),
1829
+ "",
1830
+ "End with a one-line, plain-text note of what you corrected (or 'No",
1831
+ "corrections needed.'), plus one line on what you proposed, if anything."
1294
1832
  ].join("\n");
1295
1833
  const review = await runPass({
1296
1834
  prompt: reviewPrompt,
@@ -1314,6 +1852,53 @@ ${review.summary}`);
1314
1852
  eventsComplete
1315
1853
  };
1316
1854
  }
1855
+ async function runAdditionsInstrumentationPass(opts) {
1856
+ const { repoPath, config, session, playbook, acceptedEvents, budgetUsd, progress } = opts;
1857
+ if (!acceptedEvents.length || budgetUsd <= 0) {
1858
+ return { summary: "", costUsd: 0, ran: false };
1859
+ }
1860
+ applyModelAuthEnv(config, session);
1861
+ const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
1862
+ const eventLines = [];
1863
+ for (const e of acceptedEvents) {
1864
+ eventLines.push("");
1865
+ eventLines.push(`\u25A0 ${e.code}${e.label ? ` (${e.label})` : ""}`);
1866
+ if (e.description) eventLines.push(` ${e.description}`);
1867
+ if (e.rationale) eventLines.push(` where: ${e.rationale}`);
1868
+ if (e.properties?.length) {
1869
+ eventLines.push(" properties to capture if available:");
1870
+ for (const prop of e.properties) {
1871
+ eventLines.push(` \xB7 ${prop.name}${prop.required ? " (required)" : ""}${prop.description ? ` \u2014 ${prop.description}` : ""}`);
1872
+ }
1873
+ }
1874
+ }
1875
+ progress?.onPhase?.("Instrumenting the newly added events");
1876
+ const prompt = [
1877
+ "You proposed these events as universe opportunities while reviewing this",
1878
+ "repo, and the user just APPROVED adding them to the plan. Instrument them",
1879
+ "now with track(), exactly like the plan's other events: place each at the",
1880
+ "real call site (your own rationale tells you where you saw it), attach the",
1881
+ "properties available in scope, and skip anything that turns out not to have",
1882
+ "a clear trigger after all. The SDK is already installed and initialized.",
1883
+ `Project root: ${repoPath}`,
1884
+ "",
1885
+ "----- APPROVED NEW EVENTS -----",
1886
+ ...eventLines,
1887
+ "",
1888
+ "----- END -----"
1889
+ ].join("\n");
1890
+ const pass = await runPass({
1891
+ prompt,
1892
+ systemPrompt: systemPrompt9,
1893
+ repoPath,
1894
+ model: config.model,
1895
+ effort: config.effort,
1896
+ maxTurns: 25,
1897
+ budgetUsd,
1898
+ progress
1899
+ });
1900
+ return { summary: pass.summary, costUsd: pass.costUsd, ran: true };
1901
+ }
1317
1902
  function isLimitStop(err) {
1318
1903
  const msg = err?.message ?? "";
1319
1904
  return /max(imum)?[\s_-]*(turn|budget)|budget.*exceeded|number of turns/i.test(msg);
@@ -1423,102 +2008,6 @@ function short(s, max = 60) {
1423
2008
  return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
1424
2009
  }
1425
2010
 
1426
- // src/core/git.ts
1427
- import { spawn } from "child_process";
1428
- import { createHash } from "crypto";
1429
- import { readFile as readFile2 } from "fs/promises";
1430
- import { basename, join as join2 } from "path";
1431
- async function takeCheckpoint(repoPath) {
1432
- const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1433
- if (!isRepo) return { isRepo: false };
1434
- const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1435
- const head = await run(repoPath, ["rev-parse", "HEAD"]);
1436
- return {
1437
- isRepo: true,
1438
- baseRef: head.ok ? head.stdout.trim() : void 0,
1439
- branch: branch || void 0
1440
- };
1441
- }
1442
- async function changedFiles(repoPath, checkpoint) {
1443
- if (!checkpoint.isRepo) return [];
1444
- const tracked = await run(repoPath, ["diff", "--name-only"]);
1445
- const untracked = await run(repoPath, [
1446
- "ls-files",
1447
- "--others",
1448
- "--exclude-standard"
1449
- ]);
1450
- const set = /* @__PURE__ */ new Set();
1451
- for (const f of `${tracked.stdout}
1452
- ${untracked.stdout}`.split("\n")) {
1453
- if (f.trim()) set.add(f.trim());
1454
- }
1455
- return [...set];
1456
- }
1457
- function revertHint(checkpoint) {
1458
- if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1459
- return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1460
- }
1461
- async function isWorkingTreeClean(repoPath) {
1462
- const status = await run(repoPath, ["status", "--porcelain"]);
1463
- return status.ok && status.stdout.trim() === "";
1464
- }
1465
- async function revertToCheckpoint(repoPath, checkpoint) {
1466
- if (!checkpoint.isRepo) return false;
1467
- if (checkpoint.baseRef) {
1468
- const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
1469
- const clean2 = await run(repoPath, ["clean", "-fd"]);
1470
- return reset.ok && clean2.ok;
1471
- }
1472
- const restore = await run(repoPath, ["restore", "."]);
1473
- const clean = await run(repoPath, ["clean", "-fd"]);
1474
- return restore.ok && clean.ok;
1475
- }
1476
- async function repoFingerprint(repoPath) {
1477
- const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1478
- const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
1479
- return createHash("sha256").update(seed).digest("hex").slice(0, 16);
1480
- }
1481
- function normalizeRemote(url) {
1482
- return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1483
- }
1484
- async function scanWiredEvents(repoPath, files, eventTypes) {
1485
- const wired = /* @__PURE__ */ new Map();
1486
- const patterns = eventTypes.map((e) => ({
1487
- eventType: e,
1488
- re: new RegExp(
1489
- `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
1490
- )
1491
- }));
1492
- for (const file of files) {
1493
- let content = "";
1494
- try {
1495
- content = await readFile2(join2(repoPath, file), "utf8");
1496
- } catch {
1497
- continue;
1498
- }
1499
- for (const { eventType, re } of patterns) {
1500
- if (!wired.has(eventType) && re.test(content)) {
1501
- wired.set(eventType, file);
1502
- }
1503
- }
1504
- }
1505
- return wired;
1506
- }
1507
- function escapeRegExp(s) {
1508
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1509
- }
1510
- function run(cwd, args) {
1511
- return new Promise((resolve2) => {
1512
- const child = spawn("git", args, { cwd });
1513
- let stdout = "";
1514
- let stderr = "";
1515
- child.stdout.on("data", (d) => stdout += d.toString());
1516
- child.stderr.on("data", (d) => stderr += d.toString());
1517
- child.on("close", (code) => resolve2({ ok: code === 0, stdout, stderr }));
1518
- child.on("error", () => resolve2({ ok: false, stdout, stderr }));
1519
- });
1520
- }
1521
-
1522
2011
  // src/core/verify.ts
1523
2012
  async function pollFirstEvent(config, session, opts = {}) {
1524
2013
  if (config.offline) return { received: false };
@@ -1546,6 +2035,10 @@ async function pollFirstEvent(config, session, opts = {}) {
1546
2035
 
1547
2036
  // src/core/postflight.ts
1548
2037
  import { spawn as spawn2 } from "child_process";
2038
+ function verdictToVerified(verdict) {
2039
+ if (verdict.toolMissing || verdict.timedOut) return null;
2040
+ return verdict.ok;
2041
+ }
1549
2042
  var MAX_OUTPUT = 4e3;
1550
2043
  var DEFAULT_TIMEOUT_MS = 18e4;
1551
2044
  function runVerifyCommand(repoPath, command, opts = {}) {
@@ -1737,6 +2230,7 @@ Flutter is live today; ${theme.bright(
1737
2230
  }
1738
2231
  const stopLabel = !outcome.coreOk ? theme.warn("Stopped early \u2014 partial setup is in your working tree") : outcome.eventsComplete ? theme.success("Integration complete") : theme.success("Core integration done") + theme.muted(" \u2014 some events left as follow-ups");
1739
2232
  spin.stop(stopLabel);
2233
+ const opportunities = await collectOpportunities(repoPath, manifest, checkpoint);
1740
2234
  const files = await changedFiles(repoPath, checkpoint);
1741
2235
  if (files.length) {
1742
2236
  p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
@@ -1761,6 +2255,7 @@ Flutter is live today; ${theme.bright(
1761
2255
  const vspin = p.spinner();
1762
2256
  vspin.start(`Verifying the integration \u2014 ${theme.muted(cmd)}`);
1763
2257
  const verdict = await runVerifyCommand(repoPath, cmd);
2258
+ verified = verdictToVerified(verdict);
1764
2259
  if (verdict.toolMissing) {
1765
2260
  vspin.stop(
1766
2261
  theme.warn("Couldn't verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
@@ -1770,10 +2265,8 @@ Flutter is live today; ${theme.bright(
1770
2265
  theme.warn(`Verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
1771
2266
  );
1772
2267
  } else if (verdict.ok) {
1773
- verified = true;
1774
2268
  vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
1775
2269
  } else {
1776
- verified = false;
1777
2270
  vspin.stop(theme.alert(`Verification failed \u2014 ${cmd}`));
1778
2271
  if (verdict.output) {
1779
2272
  p.note(verdict.output, theme.warn("Verifier output"));
@@ -1789,15 +2282,69 @@ Flutter is live today; ${theme.bright(
1789
2282
  }
1790
2283
  }
1791
2284
  }
1792
- const wiredMap = await scanWiredEvents(
2285
+ const { acceptedEvents, instrumentationSnapshot } = await offerOpportunities({
1793
2286
  repoPath,
1794
- files,
1795
- manifest.events.map((e) => e.eventType)
1796
- );
1797
- const reportEvents = manifest.events.map((e) => ({
1798
- event_type: e.eventType,
1799
- status: wiredMap.has(e.eventType) ? "wired" : "skipped",
1800
- file: wiredMap.get(e.eventType)
2287
+ config,
2288
+ session,
2289
+ playbook: chosen.playbook,
2290
+ manifest,
2291
+ opportunities,
2292
+ fingerprint,
2293
+ checkpoint,
2294
+ remainingBudgetUsd: config.budgetUsd - outcome.costUsd
2295
+ });
2296
+ if (instrumentationSnapshot && chosen.playbook.verifyCommand) {
2297
+ const cmd = chosen.playbook.verifyCommand;
2298
+ const preInstrumentationVerified = verified;
2299
+ const vspin = p.spinner();
2300
+ vspin.start(`Re-verifying after instrumenting the new events \u2014 ${theme.muted(cmd)}`);
2301
+ const verdict = await runVerifyCommand(repoPath, cmd);
2302
+ verified = verdictToVerified(verdict);
2303
+ if (verdict.toolMissing) {
2304
+ vspin.stop(
2305
+ theme.warn("Couldn't re-verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
2306
+ );
2307
+ } else if (verdict.timedOut) {
2308
+ vspin.stop(
2309
+ theme.warn(`Re-verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
2310
+ );
2311
+ } else if (verdict.ok) {
2312
+ vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
2313
+ } else {
2314
+ vspin.stop(theme.alert(`Verification failed after instrumenting the new events \u2014 ${cmd}`));
2315
+ if (verdict.output) {
2316
+ p.note(verdict.output, theme.warn("Verifier output"));
2317
+ }
2318
+ const doRevert = await p.confirm({
2319
+ message: "Instrumenting the new events didn't pass the build/lint check. Revert just those edits? (The events stay in your universe \u2014 wire them on a future run.)",
2320
+ initialValue: true
2321
+ });
2322
+ if (!p.isCancel(doRevert) && doRevert) {
2323
+ if (await restoreToSnapshot(repoPath, checkpoint, instrumentationSnapshot)) {
2324
+ verified = preInstrumentationVerified;
2325
+ p.log.success(theme.success("Reverted the instrumentation edits."));
2326
+ } else {
2327
+ p.log.warn(
2328
+ theme.warn("Couldn't revert automatically \u2014 undo manually: ") + (revertHint(checkpoint) ?? "git reset --hard")
2329
+ );
2330
+ }
2331
+ } else {
2332
+ p.log.info(
2333
+ theme.muted("Left in your working tree \u2014 fix the verifier errors before committing.")
2334
+ );
2335
+ }
2336
+ }
2337
+ }
2338
+ const filesForScan = acceptedEvents.length ? await changedFiles(repoPath, checkpoint) : files;
2339
+ const eventTypesToScan = [
2340
+ ...manifest.events.map((e) => e.eventType),
2341
+ ...acceptedEvents.map((e) => e.code)
2342
+ ];
2343
+ const wiredMap = await scanWiredEvents(repoPath, filesForScan, eventTypesToScan);
2344
+ const reportEvents = eventTypesToScan.map((eventType) => ({
2345
+ event_type: eventType,
2346
+ status: wiredMap.has(eventType) ? "wired" : "skipped",
2347
+ file: wiredMap.get(eventType)
1801
2348
  }));
1802
2349
  await postRunReport(config, session, {
1803
2350
  target: chosen.playbook.target.id,
@@ -1811,7 +2358,7 @@ Flutter is live today; ${theme.bright(
1811
2358
  });
1812
2359
  p.log.info(
1813
2360
  theme.muted(
1814
- `${wiredMap.size}/${manifest.events.length} events wired \xB7 ${files.length} file${files.length === 1 ? "" : "s"} changed \xB7 ` + (verified === true ? "verified \xB7 " : verified === false ? "unverified \xB7 " : "") + `${Math.round(outcome.durationMs / 1e3)}s`
2361
+ `${wiredMap.size}/${eventTypesToScan.length} events wired \xB7 ${files.length} file${files.length === 1 ? "" : "s"} changed \xB7 ` + (verified === true ? "verified \xB7 " : verified === false ? "unverified \xB7 " : "") + `${Math.round(outcome.durationMs / 1e3)}s`
1815
2362
  )
1816
2363
  );
1817
2364
  if (!config.offline) {
@@ -1846,6 +2393,153 @@ Flutter is live today; ${theme.bright(
1846
2393
  p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
1847
2394
  return 0;
1848
2395
  }
2396
+ var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
2397
+ async function offerOpportunities(opts) {
2398
+ const { opportunities, manifest, config, session } = opts;
2399
+ const total = opportunities.events.length + opportunities.interventions.length;
2400
+ if (total === 0) return NO_ADDITIONS;
2401
+ const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
2402
+ p.note(
2403
+ [
2404
+ ...opportunities.events.map((e) => describe("event", e.code, e.rationale ?? e.description)),
2405
+ ...opportunities.interventions.map(
2406
+ (i) => describe("intervention", i.code, i.rationale ?? i.description)
2407
+ )
2408
+ ].join("\n"),
2409
+ theme.signal("Opportunities found beyond your onboarding plan")
2410
+ );
2411
+ if (config.offline) {
2412
+ p.log.info(theme.muted("Offline mode \u2014 proposals shown only, nothing submitted."));
2413
+ return NO_ADDITIONS;
2414
+ }
2415
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
2416
+ p.log.info(
2417
+ theme.muted("Non-interactive run \u2014 proposals were not submitted. Re-run interactively to add them.")
2418
+ );
2419
+ return NO_ADDITIONS;
2420
+ }
2421
+ const selection = await p.multiselect({
2422
+ message: "Add these to your Whisperr universe? (new interventions start paused)",
2423
+ options: [
2424
+ ...opportunities.events.map((e) => ({
2425
+ value: `e:${e.code}`,
2426
+ label: `event \xB7 ${e.code}`,
2427
+ hint: e.description ?? e.rationale
2428
+ })),
2429
+ ...opportunities.interventions.map((i) => ({
2430
+ value: `i:${i.code}`,
2431
+ label: `intervention \xB7 ${i.code}`,
2432
+ hint: i.description ?? i.rationale
2433
+ }))
2434
+ ],
2435
+ initialValues: [
2436
+ ...opportunities.events.map((e) => `e:${e.code}`),
2437
+ ...opportunities.interventions.map((i) => `i:${i.code}`)
2438
+ ],
2439
+ required: false
2440
+ });
2441
+ if (p.isCancel(selection) || selection.length === 0) {
2442
+ p.log.info(theme.muted("Skipped \u2014 your universe is unchanged."));
2443
+ return NO_ADDITIONS;
2444
+ }
2445
+ const chosenSet = new Set(selection);
2446
+ const knownInterventionCodes = new Set(
2447
+ manifest.events.flatMap((e) => e.interventions ?? []).map((i) => i.code)
2448
+ );
2449
+ const knownEventCodes = new Set(manifest.events.map((e) => e.eventType));
2450
+ const pickedEvents = opportunities.events.filter((e) => chosenSet.has(`e:${e.code}`));
2451
+ const pickedInterventions = opportunities.interventions.filter(
2452
+ (i) => chosenSet.has(`i:${i.code}`)
2453
+ );
2454
+ const pickedInterventionCodes = new Set(pickedInterventions.map((i) => i.code));
2455
+ const pickedEventCodes = new Set(pickedEvents.map((e) => e.code));
2456
+ const submitted = {
2457
+ events: pickedEvents.map((e) => ({
2458
+ ...e,
2459
+ links: e.links?.filter(
2460
+ (l) => knownInterventionCodes.has(l.interventionCode) || pickedInterventionCodes.has(l.interventionCode)
2461
+ )
2462
+ })),
2463
+ interventions: pickedInterventions.map((i) => ({
2464
+ ...i,
2465
+ links: i.links?.filter(
2466
+ (l) => knownEventCodes.has(l.eventCode) || pickedEventCodes.has(l.eventCode)
2467
+ )
2468
+ }))
2469
+ };
2470
+ let result;
2471
+ try {
2472
+ result = await withSpinner(
2473
+ "Adding to your universe",
2474
+ () => submitAdditions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
2475
+ );
2476
+ } catch (err) {
2477
+ p.log.warn(
2478
+ theme.warn("Couldn't add the proposals") + theme.muted(` \u2014 ${err.message}. Your universe is unchanged.`)
2479
+ );
2480
+ return NO_ADDITIONS;
2481
+ }
2482
+ const lines = result.outcomes.map((o) => {
2483
+ const mark = o.status === "applied" ? theme.success("\u2713") : o.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
2484
+ const detail = o.status === "duplicate" ? theme.muted(` (already in your universe${o.duplicateOf && o.duplicateOf !== o.code ? ` as ${o.duplicateOf}` : ""})`) : o.status === "invalid" && o.reason ? theme.muted(` (${o.reason})`) : "";
2485
+ return `${mark} ${o.kind} ${theme.bright(o.code)}${detail}`;
2486
+ });
2487
+ lines.push(
2488
+ theme.muted(
2489
+ `${result.applied} added \xB7 ${result.duplicates} already present \xB7 ${result.invalid} rejected`
2490
+ )
2491
+ );
2492
+ if (submitted.interventions.length) {
2493
+ lines.push(theme.muted("New interventions start paused \u2014 activate them in your dashboard."));
2494
+ }
2495
+ if (result.policyRegen?.status === "pending") {
2496
+ lines.push(
2497
+ theme.muted("Runtime policy update queued \u2014 the additions go live once it completes.")
2498
+ );
2499
+ }
2500
+ p.note(lines.join("\n"), theme.signal("Universe updated"));
2501
+ if (result.policyRegen?.status === "failed") {
2502
+ p.log.warn(
2503
+ theme.warn("Live runtime NOT updated") + theme.muted(
2504
+ " \u2014 the additions were recorded, but the runtime policy wasn't regenerated" + (result.policyRegen.reason ? `: ${result.policyRegen.reason}` : "") + ". They won't drive live decisions until the policy regenerates."
2505
+ )
2506
+ );
2507
+ }
2508
+ const appliedEventCodes = new Set(
2509
+ result.outcomes.filter((o) => o.kind === "event" && o.status === "applied").map((o) => o.code)
2510
+ );
2511
+ const acceptedEvents = pickedEvents.filter((e) => appliedEventCodes.has(e.code));
2512
+ if (!acceptedEvents.length) return NO_ADDITIONS;
2513
+ const prePass = await snapshotChanges(opts.repoPath, opts.checkpoint);
2514
+ const spin = p.spinner();
2515
+ spin.start(theme.bright("Instrumenting the newly added events"));
2516
+ try {
2517
+ const pass = await runAdditionsInstrumentationPass({
2518
+ repoPath: opts.repoPath,
2519
+ config,
2520
+ session,
2521
+ playbook: opts.playbook,
2522
+ acceptedEvents,
2523
+ budgetUsd: opts.remainingBudgetUsd
2524
+ });
2525
+ if (pass.ran) {
2526
+ spin.stop(theme.success("New events instrumented"));
2527
+ if (pass.summary.trim()) p.log.message(pass.summary.trim());
2528
+ } else {
2529
+ spin.stop(
2530
+ theme.warn("Skipped instrumenting the new events") + theme.muted(
2531
+ " \u2014 the spend limit was reached. They're in your universe; wire them on a future run."
2532
+ )
2533
+ );
2534
+ }
2535
+ } catch (err) {
2536
+ spin.stop(
2537
+ theme.warn("Couldn't instrument the new events") + theme.muted(` \u2014 ${err.message}. They're in your universe; wire them on a future run.`)
2538
+ );
2539
+ }
2540
+ const edited = !snapshotsEqual(prePass, await snapshotChanges(opts.repoPath, opts.checkpoint));
2541
+ return { acceptedEvents, instrumentationSnapshot: edited ? prePass : null };
2542
+ }
1849
2543
  async function maybeRevert(repoPath, checkpoint, reason) {
1850
2544
  if (!checkpoint.isRepo) return false;
1851
2545
  const files = await changedFiles(repoPath, checkpoint);
@@ -1946,7 +2640,7 @@ function summarizeManifest(m) {
1946
2640
  // src/index.ts
1947
2641
  var modulePath = fileURLToPath(import.meta.url);
1948
2642
  function packageVersion() {
1949
- const packagePath = join3(dirname(modulePath), "..", "package.json");
2643
+ const packagePath = join4(dirname2(modulePath), "..", "package.json");
1950
2644
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
1951
2645
  return pkg.version ?? "0.0.0";
1952
2646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whisperr/wizard",
3
- "version": "0.1.15",
3
+ "version": "0.2.1",
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",