@whisperr/wizard 0.2.0 → 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 +646 -111
  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
@@ -1148,6 +1148,341 @@ function mockManifest(appId, config) {
1148
1148
  // src/core/agent.ts
1149
1149
  import { query } from "@anthropic-ai/claude-agent-sdk";
1150
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
+
1151
1486
  // src/core/playbooks/shared-prompt.ts
1152
1487
  var BASE_WIZARD_PROMPT = `
1153
1488
  You are the Whisperr Wizard \u2014 an expert integration agent running INSIDE a
@@ -1324,6 +1659,46 @@ function renderEventsBrief(m) {
1324
1659
  }
1325
1660
  return lines.join("\n");
1326
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
+ }
1327
1702
  function coverageNote(coverage) {
1328
1703
  if (!coverage?.length) return "";
1329
1704
  const here = coverage.find((c) => c.sameSurface && c.status === "wired");
@@ -1448,8 +1823,12 @@ ${events.summary}`);
1448
1823
  "4. Coverage \u2014 every gateway/callback path that should emit an event does;",
1449
1824
  " no recurring or secondary path left as a dead zone.",
1450
1825
  "Change ONLY what is genuinely wrong or incomplete \u2014 do not churn correct",
1451
- "calls or re-explore the whole repo. Be surgical. End with a one-line,",
1452
- "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."
1453
1832
  ].join("\n");
1454
1833
  const review = await runPass({
1455
1834
  prompt: reviewPrompt,
@@ -1473,6 +1852,53 @@ ${review.summary}`);
1473
1852
  eventsComplete
1474
1853
  };
1475
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
+ }
1476
1902
  function isLimitStop(err) {
1477
1903
  const msg = err?.message ?? "";
1478
1904
  return /max(imum)?[\s_-]*(turn|budget)|budget.*exceeded|number of turns/i.test(msg);
@@ -1582,102 +2008,6 @@ function short(s, max = 60) {
1582
2008
  return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
1583
2009
  }
1584
2010
 
1585
- // src/core/git.ts
1586
- import { spawn } from "child_process";
1587
- import { createHash } from "crypto";
1588
- import { readFile as readFile2 } from "fs/promises";
1589
- import { basename, join as join2 } from "path";
1590
- async function takeCheckpoint(repoPath) {
1591
- const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1592
- if (!isRepo) return { isRepo: false };
1593
- const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1594
- const head = await run(repoPath, ["rev-parse", "HEAD"]);
1595
- return {
1596
- isRepo: true,
1597
- baseRef: head.ok ? head.stdout.trim() : void 0,
1598
- branch: branch || void 0
1599
- };
1600
- }
1601
- async function changedFiles(repoPath, checkpoint) {
1602
- if (!checkpoint.isRepo) return [];
1603
- const tracked = await run(repoPath, ["diff", "--name-only"]);
1604
- const untracked = await run(repoPath, [
1605
- "ls-files",
1606
- "--others",
1607
- "--exclude-standard"
1608
- ]);
1609
- const set = /* @__PURE__ */ new Set();
1610
- for (const f of `${tracked.stdout}
1611
- ${untracked.stdout}`.split("\n")) {
1612
- if (f.trim()) set.add(f.trim());
1613
- }
1614
- return [...set];
1615
- }
1616
- function revertHint(checkpoint) {
1617
- if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1618
- return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1619
- }
1620
- async function isWorkingTreeClean(repoPath) {
1621
- const status = await run(repoPath, ["status", "--porcelain"]);
1622
- return status.ok && status.stdout.trim() === "";
1623
- }
1624
- async function revertToCheckpoint(repoPath, checkpoint) {
1625
- if (!checkpoint.isRepo) return false;
1626
- if (checkpoint.baseRef) {
1627
- const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
1628
- const clean2 = await run(repoPath, ["clean", "-fd"]);
1629
- return reset.ok && clean2.ok;
1630
- }
1631
- const restore = await run(repoPath, ["restore", "."]);
1632
- const clean = await run(repoPath, ["clean", "-fd"]);
1633
- return restore.ok && clean.ok;
1634
- }
1635
- async function repoFingerprint(repoPath) {
1636
- const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1637
- const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
1638
- return createHash("sha256").update(seed).digest("hex").slice(0, 16);
1639
- }
1640
- function normalizeRemote(url) {
1641
- return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1642
- }
1643
- async function scanWiredEvents(repoPath, files, eventTypes) {
1644
- const wired = /* @__PURE__ */ new Map();
1645
- const patterns = eventTypes.map((e) => ({
1646
- eventType: e,
1647
- re: new RegExp(
1648
- `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
1649
- )
1650
- }));
1651
- for (const file of files) {
1652
- let content = "";
1653
- try {
1654
- content = await readFile2(join2(repoPath, file), "utf8");
1655
- } catch {
1656
- continue;
1657
- }
1658
- for (const { eventType, re } of patterns) {
1659
- if (!wired.has(eventType) && re.test(content)) {
1660
- wired.set(eventType, file);
1661
- }
1662
- }
1663
- }
1664
- return wired;
1665
- }
1666
- function escapeRegExp(s) {
1667
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1668
- }
1669
- function run(cwd, args) {
1670
- return new Promise((resolve2) => {
1671
- const child = spawn("git", args, { cwd });
1672
- let stdout = "";
1673
- let stderr = "";
1674
- child.stdout.on("data", (d) => stdout += d.toString());
1675
- child.stderr.on("data", (d) => stderr += d.toString());
1676
- child.on("close", (code) => resolve2({ ok: code === 0, stdout, stderr }));
1677
- child.on("error", () => resolve2({ ok: false, stdout, stderr }));
1678
- });
1679
- }
1680
-
1681
2011
  // src/core/verify.ts
1682
2012
  async function pollFirstEvent(config, session, opts = {}) {
1683
2013
  if (config.offline) return { received: false };
@@ -1705,6 +2035,10 @@ async function pollFirstEvent(config, session, opts = {}) {
1705
2035
 
1706
2036
  // src/core/postflight.ts
1707
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
+ }
1708
2042
  var MAX_OUTPUT = 4e3;
1709
2043
  var DEFAULT_TIMEOUT_MS = 18e4;
1710
2044
  function runVerifyCommand(repoPath, command, opts = {}) {
@@ -1896,6 +2230,7 @@ Flutter is live today; ${theme.bright(
1896
2230
  }
1897
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");
1898
2232
  spin.stop(stopLabel);
2233
+ const opportunities = await collectOpportunities(repoPath, manifest, checkpoint);
1899
2234
  const files = await changedFiles(repoPath, checkpoint);
1900
2235
  if (files.length) {
1901
2236
  p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
@@ -1920,6 +2255,7 @@ Flutter is live today; ${theme.bright(
1920
2255
  const vspin = p.spinner();
1921
2256
  vspin.start(`Verifying the integration \u2014 ${theme.muted(cmd)}`);
1922
2257
  const verdict = await runVerifyCommand(repoPath, cmd);
2258
+ verified = verdictToVerified(verdict);
1923
2259
  if (verdict.toolMissing) {
1924
2260
  vspin.stop(
1925
2261
  theme.warn("Couldn't verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
@@ -1929,10 +2265,8 @@ Flutter is live today; ${theme.bright(
1929
2265
  theme.warn(`Verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
1930
2266
  );
1931
2267
  } else if (verdict.ok) {
1932
- verified = true;
1933
2268
  vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
1934
2269
  } else {
1935
- verified = false;
1936
2270
  vspin.stop(theme.alert(`Verification failed \u2014 ${cmd}`));
1937
2271
  if (verdict.output) {
1938
2272
  p.note(verdict.output, theme.warn("Verifier output"));
@@ -1948,15 +2282,69 @@ Flutter is live today; ${theme.bright(
1948
2282
  }
1949
2283
  }
1950
2284
  }
1951
- const wiredMap = await scanWiredEvents(
2285
+ const { acceptedEvents, instrumentationSnapshot } = await offerOpportunities({
1952
2286
  repoPath,
1953
- files,
1954
- manifest.events.map((e) => e.eventType)
1955
- );
1956
- const reportEvents = manifest.events.map((e) => ({
1957
- event_type: e.eventType,
1958
- status: wiredMap.has(e.eventType) ? "wired" : "skipped",
1959
- 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)
1960
2348
  }));
1961
2349
  await postRunReport(config, session, {
1962
2350
  target: chosen.playbook.target.id,
@@ -1970,7 +2358,7 @@ Flutter is live today; ${theme.bright(
1970
2358
  });
1971
2359
  p.log.info(
1972
2360
  theme.muted(
1973
- `${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`
1974
2362
  )
1975
2363
  );
1976
2364
  if (!config.offline) {
@@ -2005,6 +2393,153 @@ Flutter is live today; ${theme.bright(
2005
2393
  p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
2006
2394
  return 0;
2007
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
+ }
2008
2543
  async function maybeRevert(repoPath, checkpoint, reason) {
2009
2544
  if (!checkpoint.isRepo) return false;
2010
2545
  const files = await changedFiles(repoPath, checkpoint);
@@ -2105,7 +2640,7 @@ function summarizeManifest(m) {
2105
2640
  // src/index.ts
2106
2641
  var modulePath = fileURLToPath(import.meta.url);
2107
2642
  function packageVersion() {
2108
- const packagePath = join3(dirname(modulePath), "..", "package.json");
2643
+ const packagePath = join4(dirname2(modulePath), "..", "package.json");
2109
2644
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
2110
2645
  return pkg.version ?? "0.0.0";
2111
2646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whisperr/wizard",
3
- "version": "0.2.0",
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",