@whisperr/wizard 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -8
  2. package/dist/index.js +663 -733
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync, realpathSync } from "fs";
5
- import { dirname as dirname2, join as join4 } from "path";
6
- import { fileURLToPath } from "url";
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath as fileURLToPath2 } from "url";
7
6
 
8
7
  // src/cli.ts
9
8
  import { resolve as resolve2 } from "path";
@@ -13,7 +12,7 @@ import open2 from "open";
13
12
  // src/core/config.ts
14
13
  var DEFAULT_API_BASE = "https://api.whisperr.net";
15
14
  var DEFAULT_MODEL = "claude-sonnet-5";
16
- var DEFAULT_PLANNER_MODEL = "claude-opus-4-8";
15
+ var DEFAULT_PLANNER_MODEL = "claude-opus-5";
17
16
  var DEFAULT_EFFORT = "high";
18
17
  var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
19
18
  function resolveEffort() {
@@ -41,7 +40,10 @@ function resolveConfig(flags = {}) {
41
40
  // cleanly and keeps whatever already landed. Real runs finish well under it.
42
41
  budgetUsd: Number(process.env.WHISPERR_WIZARD_BUDGET_USD) || 25,
43
42
  directAnthropicKey,
44
- offline
43
+ offline,
44
+ reviewPlan: flags.reviewPlan ?? false,
45
+ proposeOnly: flags.proposeOnly ?? false,
46
+ strictReview: flags.strictReview ?? false
45
47
  };
46
48
  }
47
49
 
@@ -1124,7 +1126,18 @@ function mockManifest(appId, config) {
1124
1126
  appName: "Acme (dev)",
1125
1127
  ingestionApiKey: "wrk_offline_demo_key_do_not_use",
1126
1128
  ingestionBaseUrl: config.apiBaseUrl,
1127
- businessContext: "B2C subscription app. Free trial converts to a paid monthly plan. Churn risk concentrates around trial end and the first failed payment.",
1129
+ // Same labelled shape whisperr-go renders from the app's onboarding
1130
+ // business context, so an offline run exercises the real prompt.
1131
+ businessContext: [
1132
+ "Product: Acme (Acme Inc)",
1133
+ "What it does: B2C subscription app; a free trial converts to a paid monthly plan",
1134
+ "Industry: Software / Productivity",
1135
+ "Who the paying end user is: individual consumers on the trial-to-paid path",
1136
+ "How it charges: monthly subscription",
1137
+ "Activation moment: first project created after signup",
1138
+ "Churn-risk signals onboarding identified: trial ending unused; first failed payment",
1139
+ "Healthy-usage signals: weekly active project edits"
1140
+ ].join("\n"),
1128
1141
  identify: {
1129
1142
  traits: [
1130
1143
  { name: "plan", description: "free | trial | pro" },
@@ -1181,8 +1194,8 @@ import {
1181
1194
  // src/core/git.ts
1182
1195
  import { spawn } from "child_process";
1183
1196
  import { createHash } from "crypto";
1184
- import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
1185
- import { basename, dirname, join as join2 } from "path";
1197
+ import { readFile as readFile2 } from "fs/promises";
1198
+ import { basename, join as join2 } from "path";
1186
1199
  async function takeCheckpoint(repoPath) {
1187
1200
  const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1188
1201
  if (!isRepo) return { isRepo: false };
@@ -1213,21 +1226,6 @@ function revertHint(checkpoint) {
1213
1226
  if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1214
1227
  return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1215
1228
  }
1216
- async function wasTrackedAtCheckpoint(repoPath, checkpoint, file) {
1217
- if (!checkpoint.isRepo) return false;
1218
- if (checkpoint.baseRef) {
1219
- const res2 = await run(repoPath, [
1220
- "ls-tree",
1221
- "--name-only",
1222
- checkpoint.baseRef,
1223
- "--",
1224
- file
1225
- ]);
1226
- return res2.ok && res2.stdout.trim() !== "";
1227
- }
1228
- const res = await run(repoPath, ["ls-files", "--", file]);
1229
- return res.ok && res.stdout.trim() !== "";
1230
- }
1231
1229
  async function isWorkingTreeClean(repoPath) {
1232
1230
  const status = await run(repoPath, ["status", "--porcelain"]);
1233
1231
  return status.ok && status.stdout.trim() === "";
@@ -1243,54 +1241,6 @@ async function revertToCheckpoint(repoPath, checkpoint) {
1243
1241
  const clean = await run(repoPath, ["clean", "-fd"]);
1244
1242
  return restore.ok && clean.ok;
1245
1243
  }
1246
- async function snapshotChanges(repoPath, checkpoint) {
1247
- const snapshot = /* @__PURE__ */ new Map();
1248
- for (const file of await changedFiles(repoPath, checkpoint)) {
1249
- try {
1250
- snapshot.set(file, await readFile2(join2(repoPath, file)));
1251
- } catch {
1252
- snapshot.set(file, null);
1253
- }
1254
- }
1255
- return snapshot;
1256
- }
1257
- function snapshotsEqual(a, b) {
1258
- if (a.size !== b.size) return false;
1259
- for (const [file, content] of a) {
1260
- const other = b.get(file);
1261
- if (other === void 0) return false;
1262
- if (content === null || other === null) {
1263
- if (content !== other) return false;
1264
- } else if (!content.equals(other)) {
1265
- return false;
1266
- }
1267
- }
1268
- return true;
1269
- }
1270
- async function restoreToSnapshot(repoPath, checkpoint, snapshot) {
1271
- try {
1272
- const current = await changedFiles(repoPath, checkpoint);
1273
- for (const file of /* @__PURE__ */ new Set([...current, ...snapshot.keys()])) {
1274
- const recorded = snapshot.get(file);
1275
- const path = join2(repoPath, file);
1276
- if (recorded === void 0) {
1277
- if (checkpoint.baseRef) {
1278
- const co = await run(repoPath, ["checkout", checkpoint.baseRef, "--", file]);
1279
- if (co.ok) continue;
1280
- }
1281
- await rm(path, { force: true });
1282
- } else if (recorded === null) {
1283
- await rm(path, { force: true });
1284
- } else {
1285
- await mkdir(dirname(path), { recursive: true });
1286
- await writeFile(path, recorded);
1287
- }
1288
- }
1289
- return true;
1290
- } catch {
1291
- return false;
1292
- }
1293
- }
1294
1244
  async function repoFingerprint(repoPath) {
1295
1245
  const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1296
1246
  const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
@@ -1352,248 +1302,6 @@ function run(cwd, args) {
1352
1302
  });
1353
1303
  }
1354
1304
 
1355
- // src/core/opportunities.ts
1356
- import { readFile as readFile3, rm as rm2 } from "fs/promises";
1357
- import { join as join3 } from "path";
1358
- var OPPORTUNITIES_FILE = "whisperr-opportunities.json";
1359
- function normalizeCode(value) {
1360
- return value.trim().toLowerCase().replace(/[ \-./]/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
1361
- }
1362
- async function collectOpportunities(repoPath, manifest, checkpoint) {
1363
- const path = join3(repoPath, OPPORTUNITIES_FILE);
1364
- if (await wasTrackedAtCheckpoint(repoPath, checkpoint, OPPORTUNITIES_FILE)) {
1365
- return { events: [], interventions: [] };
1366
- }
1367
- let rawText;
1368
- try {
1369
- rawText = await readFile3(path, "utf8");
1370
- } catch {
1371
- return { events: [], interventions: [] };
1372
- }
1373
- await rm2(path, { force: true }).catch(() => {
1374
- });
1375
- let raw;
1376
- try {
1377
- raw = JSON.parse(rawText);
1378
- } catch {
1379
- return { events: [], interventions: [] };
1380
- }
1381
- return sanitizeOpportunities(raw, manifest);
1382
- }
1383
- function sanitizeOpportunities(raw, manifest) {
1384
- const out = { events: [], interventions: [] };
1385
- if (typeof raw !== "object" || raw === null) return out;
1386
- const doc = raw;
1387
- const knownEvents = new Set(manifest.events.map((e) => normalizeCode(e.eventType)));
1388
- const knownInterventions = /* @__PURE__ */ new Set([
1389
- ...manifest.events.flatMap((e) => e.interventions ?? []).map((i) => normalizeCode(i.code)),
1390
- ...(manifest.interventions ?? []).map((i) => normalizeCode(i.code))
1391
- ]);
1392
- const seenEvents = /* @__PURE__ */ new Set();
1393
- for (const entry of asArray(doc.events)) {
1394
- const ev = coerceEvent(entry);
1395
- if (!ev) continue;
1396
- if (knownEvents.has(ev.code) || seenEvents.has(ev.code)) continue;
1397
- seenEvents.add(ev.code);
1398
- out.events.push(ev);
1399
- }
1400
- const seenInterventions = /* @__PURE__ */ new Set();
1401
- for (const entry of asArray(doc.interventions)) {
1402
- const iv = coerceIntervention(entry);
1403
- if (!iv) continue;
1404
- if (knownInterventions.has(iv.code) || seenInterventions.has(iv.code)) continue;
1405
- seenInterventions.add(iv.code);
1406
- out.interventions.push(iv);
1407
- }
1408
- return out;
1409
- }
1410
- var SUBMIT_TIMEOUT_MS = 3e4;
1411
- async function submitAdditions(config, session, target9, repoFingerprint2, opportunities) {
1412
- const res = await fetch(`${config.apiBaseUrl}/wizard/universe/additions`, {
1413
- method: "POST",
1414
- signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1415
- headers: {
1416
- "Content-Type": "application/json",
1417
- Authorization: `Bearer ${session.token}`
1418
- },
1419
- body: JSON.stringify({
1420
- target: target9,
1421
- repoFingerprint: repoFingerprint2,
1422
- events: opportunities.events,
1423
- interventions: opportunities.interventions
1424
- })
1425
- });
1426
- if (!res.ok) {
1427
- const body = await res.text().catch(() => "");
1428
- throw new Error(
1429
- `Submitting universe additions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1430
- );
1431
- }
1432
- return await res.json();
1433
- }
1434
- async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
1435
- const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
1436
- method: "POST",
1437
- signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1438
- headers: {
1439
- "Content-Type": "application/json",
1440
- Authorization: `Bearer ${session.token}`
1441
- },
1442
- body: JSON.stringify({
1443
- target: target9,
1444
- repoFingerprint: repoFingerprint2,
1445
- events: opportunities.events,
1446
- interventions: opportunities.interventions
1447
- })
1448
- });
1449
- if (res.status === 404) return null;
1450
- if (!res.ok) {
1451
- const body = await res.text().catch(() => "");
1452
- throw new Error(
1453
- `Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1454
- );
1455
- }
1456
- return await res.json();
1457
- }
1458
- async function fetchSuggestions(config, session, statuses) {
1459
- if (config.offline) return [];
1460
- try {
1461
- const statusQuery = statuses.map(encodeURIComponent).join(",");
1462
- const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
1463
- const res = await fetch(url, {
1464
- signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1465
- headers: { Authorization: `Bearer ${session.token}` }
1466
- });
1467
- if (!res.ok) return [];
1468
- const body = await res.json();
1469
- return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
1470
- } catch {
1471
- return [];
1472
- }
1473
- }
1474
- function isSuggestionRecord(value) {
1475
- if (typeof value !== "object" || value === null) return false;
1476
- const record = value;
1477
- return typeof record.id === "string" && (record.kind === "event" || record.kind === "intervention") && typeof record.code === "string" && (record.status === "proposed" || record.status === "approved" || record.status === "rejected" || record.status === "integrated") && typeof record.payload === "object" && record.payload !== null;
1478
- }
1479
- async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
1480
- if (config.offline) return false;
1481
- try {
1482
- const res = await fetch(
1483
- `${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
1484
- {
1485
- method: "POST",
1486
- signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1487
- headers: {
1488
- "Content-Type": "application/json",
1489
- Authorization: `Bearer ${session.token}`
1490
- },
1491
- body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
1492
- }
1493
- );
1494
- return res.status === 200;
1495
- } catch {
1496
- return false;
1497
- }
1498
- }
1499
- function asArray(value) {
1500
- return Array.isArray(value) ? value : [];
1501
- }
1502
- function asString(value) {
1503
- if (typeof value !== "string") return void 0;
1504
- const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
1505
- return cleaned ? cleaned : void 0;
1506
- }
1507
- function asConfidence(value) {
1508
- if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1509
- return Math.min(1, Math.max(0, value));
1510
- }
1511
- function asWeight(value) {
1512
- if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1513
- if (value < 0 || value > 1) return void 0;
1514
- return value;
1515
- }
1516
- function coerceEvent(entry) {
1517
- if (typeof entry !== "object" || entry === null) return null;
1518
- const e = entry;
1519
- const code = normalizeCode(asString(e.code) ?? "");
1520
- if (!code) return null;
1521
- const properties = [];
1522
- for (const prop of asArray(e.properties)) {
1523
- if (typeof prop === "string") {
1524
- if (prop.trim()) properties.push({ name: prop.trim() });
1525
- continue;
1526
- }
1527
- if (typeof prop !== "object" || prop === null) continue;
1528
- const pr = prop;
1529
- const name = asString(pr.name);
1530
- if (!name) continue;
1531
- const desc = asString(pr.description);
1532
- properties.push({
1533
- name,
1534
- ...desc ? { description: desc } : {},
1535
- ...pr.required === true ? { required: true } : {}
1536
- });
1537
- }
1538
- const links = [];
1539
- for (const link of asArray(e.links)) {
1540
- if (typeof link !== "object" || link === null) continue;
1541
- const l = link;
1542
- const interventionCode = normalizeCode(asString(l.interventionCode) ?? "");
1543
- if (!interventionCode) continue;
1544
- const weight = asWeight(l.weight);
1545
- links.push({ interventionCode, ...weight !== void 0 ? { weight } : {} });
1546
- }
1547
- const side = normalizeSide(asString(e.side));
1548
- return {
1549
- code,
1550
- label: asString(e.label),
1551
- description: asString(e.description),
1552
- ...side ? { side } : {},
1553
- rationale: asString(e.rationale),
1554
- expectedEffect: asString(e.expectedEffect),
1555
- confidence: asConfidence(e.confidence),
1556
- ...properties.length ? { properties } : {},
1557
- ...links.length ? { links } : {}
1558
- };
1559
- }
1560
- function coerceIntervention(entry) {
1561
- if (typeof entry !== "object" || entry === null) return null;
1562
- const i = entry;
1563
- const code = normalizeCode(asString(i.code) ?? "");
1564
- if (!code) return null;
1565
- const links = [];
1566
- for (const link of asArray(i.links)) {
1567
- if (typeof link !== "object" || link === null) continue;
1568
- const l = link;
1569
- const eventCode = normalizeCode(asString(l.eventCode) ?? "");
1570
- if (!eventCode) continue;
1571
- const weight = asWeight(l.weight);
1572
- links.push({ eventCode, ...weight !== void 0 ? { weight } : {} });
1573
- }
1574
- return {
1575
- code,
1576
- label: asString(i.label),
1577
- description: asString(i.description),
1578
- rationale: asString(i.rationale),
1579
- expectedEffect: asString(i.expectedEffect),
1580
- confidence: asConfidence(i.confidence),
1581
- ...links.length ? { links } : {}
1582
- };
1583
- }
1584
- function normalizeSide(value) {
1585
- switch (value?.toLowerCase()) {
1586
- case "frontend":
1587
- return "frontend";
1588
- case "backend":
1589
- return "backend";
1590
- case "either":
1591
- return "either";
1592
- default:
1593
- return void 0;
1594
- }
1595
- }
1596
-
1597
1305
  // src/core/playbooks/shared-prompt.ts
1598
1306
  var BASE_WIZARD_PROMPT = `
1599
1307
  You are the Whisperr Wizard \u2014 an expert integration agent running INSIDE a
@@ -1770,55 +1478,51 @@ function renderEventsBrief(m) {
1770
1478
  }
1771
1479
  return lines.join("\n");
1772
1480
  }
1773
- function renderOpportunitiesBrief(m, opportunitiesFile) {
1481
+ function renderOpportunitiesBrief(m) {
1774
1482
  const eventCodes = m.events.map((e) => e.eventType);
1775
1483
  const interventionCodes = [
1776
- ...new Set(m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code))
1484
+ .../* @__PURE__ */ new Set([
1485
+ ...m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code),
1486
+ ...(m.interventions ?? []).map((i) => i.code)
1487
+ ])
1777
1488
  ];
1778
1489
  return [
1779
- "UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
1780
- "While auditing you read this app's real lifecycle. Now sweep it",
1781
- "deliberately: walk each surface the plan should care about \u2014 signup and",
1782
- "auth, billing/payments (renewals, failures, upgrades, downgrades,",
1783
- "cancellation), the core engagement loop, support/feedback/refund flows,",
1784
- "sharing/invites, and any expiry or dormancy mechanics you saw \u2014 and for",
1785
- "each one ask: does a churn-relevant moment happen here that the plan does",
1786
- "NOT cover? Record those as PROPOSALS. Do NOT add track() calls for them.",
1787
- `Write a single JSON file at the repo root named ${opportunitiesFile}:`,
1490
+ "Rules for `opportunities` \u2014 what this app's plan is MISSING:",
1491
+ "You are reading this app's real lifecycle anyway. Sweep it deliberately:",
1492
+ "walk each surface the plan should care about \u2014 signup and auth,",
1493
+ "billing/payments (renewals, failures, upgrades, downgrades, cancellation),",
1494
+ "the core engagement loop, support/feedback/refund flows, sharing/invites,",
1495
+ "and any expiry or dormancy mechanics you see \u2014 and for each ask: does a",
1496
+ "churn-relevant moment happen here that the plan does NOT cover?",
1788
1497
  "",
1789
- "{",
1790
- ' "events": [{',
1791
- ' "code": "snake_case_event", "label": "...", "description": "...",',
1498
+ "Entry shapes:",
1499
+ ' events: [{"code": "snake_case_event", "label": "...", "description": "...",',
1792
1500
  ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1793
1501
  ' "expectedEffect": "one sentence: what improves if this is adopted",',
1794
1502
  ' "confidence": 0.0-1.0,',
1795
1503
  ' "properties": [{"name": "...", "description": "...", "required": false}],',
1796
- ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1797
- " }],",
1798
- ' "interventions": [{',
1799
- ' "code": "snake_case_strategy", "label": "...", "description": "...",',
1800
- ' "rationale": "...", "confidence": 0.0-1.0,',
1801
- ' "expectedEffect": "one sentence: what improves if this is adopted",',
1802
- ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1803
- " }]",
1804
- "}",
1504
+ ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]}]',
1505
+ ' interventions: [{"code": "snake_case_strategy", "label": "...", "description": "...",',
1506
+ ' "rationale": "...", "confidence": 0.0-1.0, "expectedEffect": "...",',
1507
+ ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]}]',
1805
1508
  "",
1806
- "Rules:",
1807
1509
  `- The plan already covers these events: ${eventCodes.join(", ") || "(none)"}.`,
1808
1510
  ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1809
1511
  " Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
1810
- "- Every proposal needs concrete code evidence in its rationale (file/flow),",
1811
- " not speculation. VERIFY before you write: re-open the file you are citing",
1812
- " and confirm the flow exists as described \u2014 a false suggestion costs more",
1813
- " trust than a missed one, so drop anything you cannot point at real code.",
1512
+ "- These go straight into the customer's universe, and the events you propose",
1513
+ " get instrumented in this same run. That raises the bar on evidence, not the",
1514
+ " volume: every proposal needs concrete code backing in its rationale, with",
1515
+ " the file/flow named. VERIFY before you write it \u2014 re-open the file you are",
1516
+ " citing and confirm the flow exists as you describe. Drop anything you",
1517
+ " cannot point at real code; a false proposal costs more trust than a missed one.",
1518
+ "- Say where you saw it in `rationale` precisely enough to wire from later \u2014",
1519
+ " it is the only placement note the wiring pass will have for these.",
1814
1520
  "- Be thorough, not shy: a feature-rich product typically yields 3-6 solid",
1815
- " event proposals and 1-3 interventions. An empty file is only the right",
1816
- " answer when the plan genuinely already covers the product's lifecycle \u2014",
1817
- " never because you stopped looking early.",
1818
- "- Every proposal should include expectedEffect: one sentence explaining what adoption should improve.",
1521
+ " event proposals and 1-3 interventions. Empty is only right when the plan",
1522
+ " genuinely covers the product's lifecycle \u2014 never because you stopped early.",
1523
+ "- Every proposal should include expectedEffect: one sentence on what adoption improves.",
1819
1524
  "- Link each proposed event to the intervention(s) it should feed (existing",
1820
- " codes or ones you propose in the same file).",
1821
- "- This file is metadata for the wizard, not app code \u2014 write it and move on."
1525
+ " codes or ones you propose alongside it)."
1822
1526
  ].join("\n");
1823
1527
  }
1824
1528
  function coverageNote(coverage) {
@@ -2140,36 +1844,56 @@ function tokenizeShellSegment(segment) {
2140
1844
 
2141
1845
  // src/core/agent.ts
2142
1846
  async function runIntegrationAgent(opts) {
2143
- const { repoPath, config, session, playbook, manifest, progress, onPlanReady } = opts;
2144
- applyModelAuthEnv(config, session);
2145
- const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
2146
- const started = Date.now();
2147
- let costUsd = 0;
1847
+ const {
1848
+ repoPath,
1849
+ config,
1850
+ session,
1851
+ playbook,
1852
+ manifest,
1853
+ progress,
1854
+ onPlanReady,
1855
+ onOpportunitiesReady
1856
+ } = opts;
1857
+ applyModelAuthEnv(config, session);
1858
+ const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
1859
+ const started = Date.now();
1860
+ let costUsd = 0;
2148
1861
  const summaries = [];
2149
1862
  let repoMap = "";
2150
1863
  let eventOutcomes = [];
2151
1864
  const phaseTimings = [];
2152
1865
  const BUDGET_FLOOR = 0.25;
1866
+ let planning = { repoMap: "", plan: null, opportunities: emptyOpportunities() };
2153
1867
  if (config.budgetUsd - costUsd > BUDGET_FLOOR) {
2154
1868
  try {
2155
- const map = await runTimedPass("Mapping your codebase", {
2156
- prompt: renderRepoMapPrompt(repoPath),
1869
+ const pass = await runTimedPass("Planning your integration", {
1870
+ prompt: renderPlanningPrompt(repoPath, playbook, manifest),
2157
1871
  systemPrompt: systemPrompt9,
2158
1872
  repoPath,
2159
1873
  model: config.plannerModel,
2160
1874
  effort: "high",
2161
- maxTurns: 40,
2162
- budgetUsd: Math.min(config.budgetUsd - costUsd, config.budgetUsd * 0.15),
1875
+ maxTurns: 60,
1876
+ budgetUsd: Math.min(config.budgetUsd - costUsd, config.budgetUsd * 0.3),
2163
1877
  allowedTools: READ_ONLY_TOOLS,
2164
1878
  progress
2165
1879
  }, phaseTimings);
2166
- costUsd += map.costUsd;
2167
- repoMap = sliceRepoMap(map.summary);
1880
+ costUsd += pass.costUsd;
1881
+ planning = parsePlanningResult(pass.summary);
2168
1882
  } catch (err) {
2169
- debugNote(`repo map pass failed: ${err.message}`);
2170
- repoMap = "";
1883
+ debugNote(`planning pass failed: ${err.message}`);
2171
1884
  }
2172
1885
  }
1886
+ repoMap = planning.repoMap;
1887
+ let scopedPlan = null;
1888
+ if (planning.plan) {
1889
+ scopedPlan = planForManifest(planning.plan, manifest);
1890
+ const reviewedPlan = await onPlanReady?.(scopedPlan);
1891
+ if (reviewedPlan !== void 0 && reviewedPlan !== null) {
1892
+ scopedPlan = planForManifest(reviewedPlan, manifest);
1893
+ }
1894
+ }
1895
+ let appliedOpportunityEvents = [];
1896
+ const opportunitiesProposed = planning.opportunities.events.length + planning.opportunities.interventions.length;
2173
1897
  const corePrompt = [
2174
1898
  `Integrate the Whisperr ${playbook.target.displayName} SDK \u2014 CORE SETUP ONLY.`,
2175
1899
  `Project root: ${repoPath}`,
@@ -2212,6 +1936,13 @@ async function runIntegrationAgent(opts) {
2212
1936
  costUsd += core.costUsd;
2213
1937
  if (core.summary) summaries.push(`Core setup:
2214
1938
  ${core.summary}`);
1939
+ if (onOpportunitiesReady && opportunitiesProposed > 0 && core.ok) {
1940
+ try {
1941
+ appliedOpportunityEvents = await onOpportunitiesReady(planning.opportunities);
1942
+ } catch (err) {
1943
+ debugNote(`universe additions failed: ${err.message}`);
1944
+ }
1945
+ }
2215
1946
  let eventsComplete = true;
2216
1947
  if (manifest.events.length > 0 && config.budgetUsd - costUsd <= BUDGET_FLOOR) {
2217
1948
  eventsComplete = false;
@@ -2224,20 +1955,7 @@ ${core.summary}`);
2224
1955
  "Events: skipped \u2014 the spend limit was reached during core setup. Re-run with a higher WHISPERR_WIZARD_BUDGET_USD to instrument events."
2225
1956
  );
2226
1957
  } else if (manifest.events.length > 0) {
2227
- const planPass = await runTimedPass("Planning event placements", {
2228
- prompt: renderEventPlanPrompt(repoPath, playbook, manifest, repoMap),
2229
- systemPrompt: systemPrompt9,
2230
- repoPath,
2231
- model: config.plannerModel,
2232
- effort: "high",
2233
- maxTurns: 40,
2234
- budgetUsd: config.budgetUsd - costUsd,
2235
- allowedTools: READ_ONLY_TOOLS,
2236
- progress
2237
- }, phaseTimings);
2238
- costUsd += planPass.costUsd;
2239
- const plan = parseEventPlan(planPass.summary);
2240
- if (!plan) {
1958
+ if (!scopedPlan) {
2241
1959
  debugNote("event plan parse failed; falling back to direct event wiring");
2242
1960
  progress?.onActivity?.("Event plan was not parseable; falling back to direct wiring");
2243
1961
  const direct = await runDirectEventsPass({
@@ -2257,21 +1975,23 @@ ${core.summary}`);
2257
1975
  if (direct.pass.summary) summaries.push(`Events:
2258
1976
  ${direct.pass.summary}`);
2259
1977
  } else {
2260
- let scopedPlan = planForManifest(plan, manifest);
2261
- const reviewedPlan = await onPlanReady?.(scopedPlan);
2262
- if (reviewedPlan !== void 0 && reviewedPlan !== null) {
2263
- scopedPlan = planForManifest(reviewedPlan, manifest);
2264
- }
2265
1978
  const placeEntries = scopedPlan.filter((entry) => entry.decision === "place");
2266
1979
  const unsureEntries = scopedPlan.filter((entry) => entry.decision === "unsure");
2267
1980
  let wire;
2268
- if (placeEntries.length && config.budgetUsd - costUsd > BUDGET_FLOOR) {
1981
+ const hasWorkToWire = placeEntries.length > 0 || appliedOpportunityEvents.length > 0;
1982
+ if (hasWorkToWire && config.budgetUsd - costUsd > BUDGET_FLOOR) {
2269
1983
  wire = await runTimedPass("Instrumenting planned events", {
2270
- prompt: renderEventWirePrompt(repoPath, playbook, repoMap, placeEntries),
1984
+ prompt: renderEventWirePrompt(
1985
+ repoPath,
1986
+ playbook,
1987
+ repoMap,
1988
+ placeEntries,
1989
+ appliedOpportunityEvents
1990
+ ),
2271
1991
  systemPrompt: systemPrompt9,
2272
1992
  repoPath,
2273
1993
  model: config.model,
2274
- effort: "high",
1994
+ effort: "medium",
2275
1995
  maxTurns: config.maxTurns,
2276
1996
  budgetUsd: config.budgetUsd - costUsd,
2277
1997
  progress
@@ -2279,7 +1999,7 @@ ${direct.pass.summary}`);
2279
1999
  costUsd += wire.costUsd;
2280
2000
  if (wire.summary) summaries.push(`Events:
2281
2001
  ${wire.summary}`);
2282
- } else if (!placeEntries.length) {
2002
+ } else if (!hasWorkToWire) {
2283
2003
  summaries.push("Events: no events had a confident placement in this surface.");
2284
2004
  }
2285
2005
  let wired = await scanManifestEvents(repoPath, manifest);
@@ -2303,7 +2023,7 @@ ${followUp.summary}`);
2303
2023
  wired = await scanManifestEvents(repoPath, manifest);
2304
2024
  }
2305
2025
  eventOutcomes = buildEventOutcomes(manifest, scopedPlan, wired);
2306
- eventsComplete = !planPass.maxedOut && !(wire?.maxedOut ?? false) && !(followUp?.maxedOut ?? false);
2026
+ eventsComplete = !(wire?.maxedOut ?? false) && !(followUp?.maxedOut ?? false);
2307
2027
  }
2308
2028
  }
2309
2029
  if (core.ok && config.budgetUsd - costUsd > BUDGET_FLOOR) {
@@ -2313,7 +2033,7 @@ ${followUp.summary}`);
2313
2033
  `Project root: ${repoPath}`,
2314
2034
  "",
2315
2035
  ...renderRepoMapSection(repoMap),
2316
- "Run `git diff` and read the surrounding code to see exactly what you added.",
2036
+ "Run `git diff` and read ONLY the code immediately around each change.",
2317
2037
  "For every identify() and track() call, verify \u2014 and FIX in place:",
2318
2038
  "1. Subject \u2014 keys to the END USER, never an admin/staff/operator/seller.",
2319
2039
  " identify() sits on the SAME surface as account_created (same person).",
@@ -2327,21 +2047,19 @@ ${followUp.summary}`);
2327
2047
  " / payment_source / amount / plan). Add the ones you missed.",
2328
2048
  "4. Coverage \u2014 every gateway/callback path that should emit an event does;",
2329
2049
  " no recurring or secondary path left as a dead zone.",
2330
- "Change ONLY what is genuinely wrong or incomplete \u2014 do not churn correct",
2331
- "calls or re-explore the whole repo. Be surgical.",
2332
- "",
2333
- renderOpportunitiesBrief(manifest, OPPORTUNITIES_FILE),
2050
+ "Work from the diff. Do NOT re-explore the repository, do NOT propose new",
2051
+ "events, and change ONLY what is genuinely wrong or incomplete. Be surgical.",
2334
2052
  "",
2335
2053
  "End with a one-line, plain-text note of what you corrected (or 'No",
2336
- "corrections needed.'), plus one line on what you proposed, if anything."
2054
+ "corrections needed.')."
2337
2055
  ].join("\n");
2338
2056
  const review = await runTimedPass("Reviewing & correcting placements", {
2339
2057
  prompt: reviewPrompt,
2340
2058
  systemPrompt: systemPrompt9,
2341
2059
  repoPath,
2342
- model: config.plannerModel,
2060
+ model: config.strictReview ? config.plannerModel : config.model,
2343
2061
  effort: "medium",
2344
- maxTurns: 40,
2062
+ maxTurns: config.strictReview ? 40 : 15,
2345
2063
  budgetUsd: config.budgetUsd - costUsd,
2346
2064
  progress
2347
2065
  }, phaseTimings);
@@ -2364,57 +2082,11 @@ ${review.summary}`);
2364
2082
  coreOk: core.ok,
2365
2083
  eventsComplete,
2366
2084
  eventOutcomes,
2367
- repoMap: repoMap || void 0
2085
+ repoMap: repoMap || void 0,
2086
+ opportunitiesProposed,
2087
+ opportunitiesApplied: appliedOpportunityEvents.length
2368
2088
  };
2369
2089
  }
2370
- async function runAdditionsInstrumentationPass(opts) {
2371
- const { repoPath, config, session, playbook, acceptedEvents, budgetUsd, repoMap, progress } = opts;
2372
- if (!acceptedEvents.length || budgetUsd <= 0) {
2373
- return { summary: "", costUsd: 0, ran: false };
2374
- }
2375
- applyModelAuthEnv(config, session);
2376
- const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
2377
- const eventLines = [];
2378
- for (const e of acceptedEvents) {
2379
- eventLines.push("");
2380
- eventLines.push(`\u25A0 ${e.code}${e.label ? ` (${e.label})` : ""}`);
2381
- if (e.description) eventLines.push(` ${e.description}`);
2382
- if (e.rationale) eventLines.push(` where: ${e.rationale}`);
2383
- if (e.properties?.length) {
2384
- eventLines.push(" properties to capture if available:");
2385
- for (const prop of e.properties) {
2386
- eventLines.push(` \xB7 ${prop.name}${prop.required ? " (required)" : ""}${prop.description ? ` \u2014 ${prop.description}` : ""}`);
2387
- }
2388
- }
2389
- }
2390
- progress?.onPhase?.("Instrumenting the newly added events");
2391
- const prompt = [
2392
- "You proposed these events as universe opportunities while reviewing this",
2393
- "repo, and the user just APPROVED adding them to the plan. Instrument them",
2394
- "now with track(), exactly like the plan's other events: place each at the",
2395
- "real call site (your own rationale tells you where you saw it), attach the",
2396
- "properties available in scope, and skip anything that turns out not to have",
2397
- "a clear trigger after all. The SDK is already installed and initialized.",
2398
- `Project root: ${repoPath}`,
2399
- "",
2400
- ...renderRepoMapSection(repoMap),
2401
- "----- APPROVED NEW EVENTS -----",
2402
- ...eventLines,
2403
- "",
2404
- "----- END -----"
2405
- ].join("\n");
2406
- const pass = await runPass({
2407
- prompt,
2408
- systemPrompt: systemPrompt9,
2409
- repoPath,
2410
- model: config.model,
2411
- effort: config.effort,
2412
- maxTurns: 25,
2413
- budgetUsd,
2414
- progress
2415
- });
2416
- return { summary: pass.summary, costUsd: pass.costUsd, ran: true };
2417
- }
2418
2090
  async function runRepairPass(opts) {
2419
2091
  const {
2420
2092
  repoPath,
@@ -2458,11 +2130,64 @@ async function runRepairPass(opts) {
2458
2130
  }
2459
2131
  var READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
2460
2132
  var FULL_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
2133
+ var PLANNING_JSON_FENCE = /```json\s*([\s\S]*?)```/;
2134
+ function scrubTerminalText(value) {
2135
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
2136
+ }
2137
+ function emptyOpportunities() {
2138
+ return { events: [], interventions: [] };
2139
+ }
2140
+ function parsePlanningResult(text) {
2141
+ const fenced = PLANNING_JSON_FENCE.exec(text);
2142
+ const payload = parseJsonObject(fenced?.[1]) ?? parseJsonObject(sliceLastJsonObject(text));
2143
+ const mapText = fenced ? text.slice(0, fenced.index) : text;
2144
+ const repoMap = sliceRepoMap(stripMapMarkers(mapText));
2145
+ if (!payload) {
2146
+ return { repoMap, plan: parseEventPlan(text), opportunities: emptyOpportunities() };
2147
+ }
2148
+ return {
2149
+ repoMap,
2150
+ plan: Array.isArray(payload.plan) ? coerceEventPlan(payload.plan) : null,
2151
+ opportunities: coerceOpportunities(payload.opportunities)
2152
+ };
2153
+ }
2154
+ function parseJsonObject(raw) {
2155
+ if (!raw?.trim()) return null;
2156
+ try {
2157
+ const parsed = JSON.parse(raw);
2158
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2159
+ const obj = parsed;
2160
+ if ("plan" in obj || "opportunities" in obj) return obj;
2161
+ }
2162
+ } catch {
2163
+ }
2164
+ return null;
2165
+ }
2166
+ function sliceLastJsonObject(text) {
2167
+ const start = text.lastIndexOf("{\n");
2168
+ const from = start >= 0 ? start : text.indexOf("{");
2169
+ if (from < 0) return null;
2170
+ return sliceBalanced(text, from, "{", "}");
2171
+ }
2172
+ function stripMapMarkers(text) {
2173
+ return text.replace(/^-+\s*REPO MAP\s*-+$/gim, "").replace(/^-+\s*END REPO MAP\s*-+$/gim, "").trim();
2174
+ }
2175
+ function coerceOpportunities(raw) {
2176
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return emptyOpportunities();
2177
+ const doc = raw;
2178
+ return {
2179
+ events: Array.isArray(doc.events) ? doc.events : [],
2180
+ interventions: Array.isArray(doc.interventions) ? doc.interventions : []
2181
+ };
2182
+ }
2461
2183
  function parseEventPlan(text) {
2462
2184
  const parsed = parseFirstJsonArray(text);
2463
2185
  if (!Array.isArray(parsed)) return null;
2186
+ return coerceEventPlan(parsed);
2187
+ }
2188
+ function coerceEventPlan(items) {
2464
2189
  const entries = [];
2465
- for (const item of parsed) {
2190
+ for (const item of items) {
2466
2191
  if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2467
2192
  const obj = item;
2468
2193
  const event = typeof obj.event === "string" ? obj.event.trim() : "";
@@ -2473,7 +2198,10 @@ function parseEventPlan(text) {
2473
2198
  const entry = {
2474
2199
  event,
2475
2200
  decision,
2476
- reason: typeof obj.reason === "string" ? obj.reason.trim() : ""
2201
+ // Model-authored text that ends up in the customer's terminal and in
2202
+ // the run report — scrub it like every other string we take from the
2203
+ // model, so it cannot restyle or spoof the surrounding UI.
2204
+ reason: typeof obj.reason === "string" ? scrubTerminalText(obj.reason) : ""
2477
2205
  };
2478
2206
  if (typeof obj.file === "string" && obj.file.trim()) {
2479
2207
  entry.file = obj.file.trim();
@@ -2491,7 +2219,7 @@ function parseEventPlan(text) {
2491
2219
  function parseFirstJsonArray(text) {
2492
2220
  const start = text.indexOf("[");
2493
2221
  if (start < 0) return null;
2494
- const json = sliceJsonArray(text, start);
2222
+ const json = sliceBalanced(text, start, "[", "]");
2495
2223
  if (!json) return null;
2496
2224
  try {
2497
2225
  return JSON.parse(json);
@@ -2499,7 +2227,7 @@ function parseFirstJsonArray(text) {
2499
2227
  return null;
2500
2228
  }
2501
2229
  }
2502
- function sliceJsonArray(text, start) {
2230
+ function sliceBalanced(text, start, open3, close) {
2503
2231
  let depth = 0;
2504
2232
  let inString = false;
2505
2233
  let escaped = false;
@@ -2517,31 +2245,15 @@ function sliceJsonArray(text, start) {
2517
2245
  }
2518
2246
  if (ch === '"') {
2519
2247
  inString = true;
2520
- } else if (ch === "[") {
2248
+ } else if (ch === open3) {
2521
2249
  depth++;
2522
- } else if (ch === "]") {
2250
+ } else if (ch === close) {
2523
2251
  depth--;
2524
2252
  if (depth === 0) return text.slice(start, i + 1);
2525
2253
  }
2526
2254
  }
2527
2255
  return null;
2528
2256
  }
2529
- function renderRepoMapPrompt(repoPath) {
2530
- return [
2531
- "Map this repository for a Whisperr SDK integration. This is READ-ONLY.",
2532
- `Project root: ${repoPath}`,
2533
- "",
2534
- "Produce a concise structured repo map as final text with these exact sections:",
2535
- "- framework + entry points",
2536
- "- auth flows enumerated: END-USER vs admin/staff, with precise file paths",
2537
- "- billing/payment/webhook surfaces",
2538
- "- analytics/tracking wrapper if any",
2539
- "- state management",
2540
- "- directory guide: which dirs hold pages/controllers/services",
2541
- "",
2542
- "This map guides event placement. List file paths precisely. No prose beyond the map."
2543
- ].join("\n");
2544
- }
2545
2257
  function sliceRepoMap(map) {
2546
2258
  return map.trim().slice(0, 8e3);
2547
2259
  }
@@ -2550,30 +2262,46 @@ function renderRepoMapSection(repoMap) {
2550
2262
  if (!map) return [];
2551
2263
  return ["----- REPO MAP -----", map, "----- END REPO MAP -----", ""];
2552
2264
  }
2553
- function renderEventPlanPrompt(repoPath, playbook, manifest, repoMap) {
2265
+ function renderPlanningPrompt(repoPath, playbook, manifest) {
2554
2266
  return [
2555
- `Plan Whisperr ${playbook.target.displayName} event placements. This is READ-ONLY.`,
2267
+ `Plan the Whisperr ${playbook.target.displayName} integration for this repo.`,
2268
+ "This pass is READ-ONLY \u2014 do not edit anything. You are deciding what the",
2269
+ "editing passes will do, so be precise about files and anchors.",
2556
2270
  `Project root: ${repoPath}`,
2557
2271
  "",
2558
- ...renderRepoMapSection(repoMap),
2559
- "Use the repo map and the events brief to decide where each event belongs.",
2560
- "Return ONLY a JSON code block containing an array with this shape:",
2561
- '[{"event":"event_name","decision":"place|skip|unsure","file":"path","anchor":"function/route/handler","properties":["prop"],"reason":"short reason"}]',
2272
+ "Produce your answer in TWO parts, in this order.",
2562
2273
  "",
2563
- "Rules:",
2274
+ "PART 1 \u2014 the repo map, as plain text under these exact sections:",
2275
+ "- framework + entry points",
2276
+ "- auth flows enumerated: END-USER vs admin/staff, with precise file paths",
2277
+ "- billing/payment/webhook surfaces",
2278
+ "- analytics/tracking wrapper if any",
2279
+ "- state management",
2280
+ "- directory guide: which dirs hold pages/controllers/services",
2281
+ "List file paths precisely. No prose beyond the map.",
2282
+ "",
2283
+ "PART 2 \u2014 a single ```json fenced block, and nothing after it:",
2284
+ "{",
2285
+ ' "plan": [{"event":"event_name","decision":"place|skip|unsure","file":"path",',
2286
+ ' "anchor":"function/route/handler","properties":["prop"],"reason":"short reason"}],',
2287
+ ' "opportunities": {"events": [], "interventions": []}',
2288
+ "}",
2289
+ "",
2290
+ "Rules for `plan` \u2014 one entry per event in the brief below:",
2564
2291
  "- decision=place only when there is a concrete end-user call site in this repo.",
2565
2292
  "- decision=skip when the event clearly lives on another surface or does not exist here.",
2566
2293
  "- decision=unsure when there is a plausible surface but the exact anchor is not proven.",
2567
2294
  "- For place decisions, include file and anchor precisely.",
2568
2295
  "- properties should list only properties likely available at that anchor.",
2569
- "- No prose beyond the JSON code block.",
2296
+ "",
2297
+ renderOpportunitiesBrief(manifest),
2570
2298
  "",
2571
2299
  "----- EVENTS -----",
2572
2300
  renderEventsBrief(manifest),
2573
2301
  "----- END EVENTS -----"
2574
2302
  ].join("\n");
2575
2303
  }
2576
- function renderEventWirePrompt(repoPath, playbook, repoMap, entries) {
2304
+ function renderEventWirePrompt(repoPath, playbook, repoMap, entries, addedEvents = []) {
2577
2305
  return [
2578
2306
  `The Whisperr ${playbook.target.displayName} SDK is installed and initialized.`,
2579
2307
  "Wire only the planned event placements below with track().",
@@ -2586,9 +2314,39 @@ function renderEventWirePrompt(repoPath, playbook, repoMap, entries) {
2586
2314
  "",
2587
2315
  "----- EVENT WIRING PLAN -----",
2588
2316
  ...renderPlanEntryLines(entries),
2589
- "----- END EVENT WIRING PLAN -----"
2317
+ "----- END EVENT WIRING PLAN -----",
2318
+ // Events the planner proposed and the universe accepted this run. They have
2319
+ // no plan entry (they weren't in the manifest when the plan was written), so
2320
+ // the rationale carries the placement — the planner already saw the site.
2321
+ ...addedEvents.length ? [
2322
+ "",
2323
+ "----- NEWLY ADDED EVENTS -----",
2324
+ "You proposed these while planning and they are now part of this app's",
2325
+ "universe. Wire them the same way \u2014 your own rationale says where you",
2326
+ "saw each one. Skip any that turn out not to have a clear trigger.",
2327
+ ...renderAddedEventLines(addedEvents),
2328
+ "----- END NEWLY ADDED EVENTS -----"
2329
+ ] : []
2590
2330
  ].join("\n");
2591
2331
  }
2332
+ function renderAddedEventLines(events) {
2333
+ const lines = [];
2334
+ for (const event of events) {
2335
+ lines.push("");
2336
+ lines.push(`\u25A0 ${event.code}${event.label ? ` (${event.label})` : ""}`);
2337
+ if (event.description) lines.push(` ${event.description}`);
2338
+ if (event.rationale) lines.push(` where: ${event.rationale}`);
2339
+ if (event.properties?.length) {
2340
+ lines.push(" properties to capture if available:");
2341
+ for (const prop of event.properties) {
2342
+ lines.push(
2343
+ ` \xB7 ${prop.name}${prop.required ? " (required)" : ""}${prop.description ? ` \u2014 ${prop.description}` : ""}`
2344
+ );
2345
+ }
2346
+ }
2347
+ }
2348
+ return lines;
2349
+ }
2592
2350
  function renderEventReconcilePrompt(repoPath, playbook, repoMap, entries) {
2593
2351
  return [
2594
2352
  `One targeted follow-up for Whisperr ${playbook.target.displayName} events.`,
@@ -2933,6 +2691,224 @@ function short(s, max = 60) {
2933
2691
  return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
2934
2692
  }
2935
2693
 
2694
+ // src/core/opportunities.ts
2695
+ function normalizeCode(value) {
2696
+ return value.trim().toLowerCase().replace(/[ \-./]/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
2697
+ }
2698
+ function sanitizeOpportunities(raw, manifest) {
2699
+ const out = { events: [], interventions: [] };
2700
+ if (typeof raw !== "object" || raw === null) return out;
2701
+ const doc = raw;
2702
+ const knownEvents = new Set(manifest.events.map((e) => normalizeCode(e.eventType)));
2703
+ const knownInterventions = /* @__PURE__ */ new Set([
2704
+ ...manifest.events.flatMap((e) => e.interventions ?? []).map((i) => normalizeCode(i.code)),
2705
+ ...(manifest.interventions ?? []).map((i) => normalizeCode(i.code))
2706
+ ]);
2707
+ const seenEvents = /* @__PURE__ */ new Set();
2708
+ for (const entry of asArray(doc.events)) {
2709
+ const ev = coerceEvent(entry);
2710
+ if (!ev) continue;
2711
+ if (knownEvents.has(ev.code) || seenEvents.has(ev.code)) continue;
2712
+ seenEvents.add(ev.code);
2713
+ out.events.push(ev);
2714
+ }
2715
+ const seenInterventions = /* @__PURE__ */ new Set();
2716
+ for (const entry of asArray(doc.interventions)) {
2717
+ const iv = coerceIntervention(entry);
2718
+ if (!iv) continue;
2719
+ if (knownInterventions.has(iv.code) || seenInterventions.has(iv.code)) continue;
2720
+ seenInterventions.add(iv.code);
2721
+ out.interventions.push(iv);
2722
+ }
2723
+ return out;
2724
+ }
2725
+ var SUBMIT_TIMEOUT_MS = 3e4;
2726
+ async function submitAdditions(config, session, target9, repoFingerprint2, opportunities) {
2727
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/additions`, {
2728
+ method: "POST",
2729
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
2730
+ headers: {
2731
+ "Content-Type": "application/json",
2732
+ Authorization: `Bearer ${session.token}`
2733
+ },
2734
+ body: JSON.stringify({
2735
+ target: target9,
2736
+ repoFingerprint: repoFingerprint2,
2737
+ events: opportunities.events,
2738
+ interventions: opportunities.interventions
2739
+ })
2740
+ });
2741
+ if (!res.ok) {
2742
+ const body = await res.text().catch(() => "");
2743
+ throw new Error(
2744
+ `Submitting universe additions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
2745
+ );
2746
+ }
2747
+ return await res.json();
2748
+ }
2749
+ async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
2750
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
2751
+ method: "POST",
2752
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
2753
+ headers: {
2754
+ "Content-Type": "application/json",
2755
+ Authorization: `Bearer ${session.token}`
2756
+ },
2757
+ body: JSON.stringify({
2758
+ target: target9,
2759
+ repoFingerprint: repoFingerprint2,
2760
+ events: opportunities.events,
2761
+ interventions: opportunities.interventions
2762
+ })
2763
+ });
2764
+ if (res.status === 404) return null;
2765
+ if (!res.ok) {
2766
+ const body = await res.text().catch(() => "");
2767
+ throw new Error(
2768
+ `Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
2769
+ );
2770
+ }
2771
+ return await res.json();
2772
+ }
2773
+ async function fetchSuggestions(config, session, statuses) {
2774
+ if (config.offline) return [];
2775
+ try {
2776
+ const statusQuery = statuses.map(encodeURIComponent).join(",");
2777
+ const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
2778
+ const res = await fetch(url, {
2779
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
2780
+ headers: { Authorization: `Bearer ${session.token}` }
2781
+ });
2782
+ if (!res.ok) return [];
2783
+ const body = await res.json();
2784
+ return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
2785
+ } catch {
2786
+ return [];
2787
+ }
2788
+ }
2789
+ function isSuggestionRecord(value) {
2790
+ if (typeof value !== "object" || value === null) return false;
2791
+ const record = value;
2792
+ return typeof record.id === "string" && (record.kind === "event" || record.kind === "intervention") && typeof record.code === "string" && (record.status === "proposed" || record.status === "approved" || record.status === "rejected" || record.status === "integrated") && typeof record.payload === "object" && record.payload !== null;
2793
+ }
2794
+ async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
2795
+ if (config.offline) return false;
2796
+ try {
2797
+ const res = await fetch(
2798
+ `${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
2799
+ {
2800
+ method: "POST",
2801
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
2802
+ headers: {
2803
+ "Content-Type": "application/json",
2804
+ Authorization: `Bearer ${session.token}`
2805
+ },
2806
+ body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
2807
+ }
2808
+ );
2809
+ return res.status === 200;
2810
+ } catch {
2811
+ return false;
2812
+ }
2813
+ }
2814
+ function asArray(value) {
2815
+ return Array.isArray(value) ? value : [];
2816
+ }
2817
+ function asString(value) {
2818
+ if (typeof value !== "string") return void 0;
2819
+ const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
2820
+ return cleaned ? cleaned : void 0;
2821
+ }
2822
+ function asConfidence(value) {
2823
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
2824
+ return Math.min(1, Math.max(0, value));
2825
+ }
2826
+ function asWeight(value) {
2827
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
2828
+ if (value < 0 || value > 1) return void 0;
2829
+ return value;
2830
+ }
2831
+ function coerceEvent(entry) {
2832
+ if (typeof entry !== "object" || entry === null) return null;
2833
+ const e = entry;
2834
+ const code = normalizeCode(asString(e.code) ?? "");
2835
+ if (!code) return null;
2836
+ const properties = [];
2837
+ for (const prop of asArray(e.properties)) {
2838
+ if (typeof prop === "string") {
2839
+ if (prop.trim()) properties.push({ name: prop.trim() });
2840
+ continue;
2841
+ }
2842
+ if (typeof prop !== "object" || prop === null) continue;
2843
+ const pr = prop;
2844
+ const name = asString(pr.name);
2845
+ if (!name) continue;
2846
+ const desc = asString(pr.description);
2847
+ properties.push({
2848
+ name,
2849
+ ...desc ? { description: desc } : {},
2850
+ ...pr.required === true ? { required: true } : {}
2851
+ });
2852
+ }
2853
+ const links = [];
2854
+ for (const link of asArray(e.links)) {
2855
+ if (typeof link !== "object" || link === null) continue;
2856
+ const l = link;
2857
+ const interventionCode = normalizeCode(asString(l.interventionCode) ?? "");
2858
+ if (!interventionCode) continue;
2859
+ const weight = asWeight(l.weight);
2860
+ links.push({ interventionCode, ...weight !== void 0 ? { weight } : {} });
2861
+ }
2862
+ const side = normalizeSide(asString(e.side));
2863
+ return {
2864
+ code,
2865
+ label: asString(e.label),
2866
+ description: asString(e.description),
2867
+ ...side ? { side } : {},
2868
+ rationale: asString(e.rationale),
2869
+ expectedEffect: asString(e.expectedEffect),
2870
+ confidence: asConfidence(e.confidence),
2871
+ ...properties.length ? { properties } : {},
2872
+ ...links.length ? { links } : {}
2873
+ };
2874
+ }
2875
+ function coerceIntervention(entry) {
2876
+ if (typeof entry !== "object" || entry === null) return null;
2877
+ const i = entry;
2878
+ const code = normalizeCode(asString(i.code) ?? "");
2879
+ if (!code) return null;
2880
+ const links = [];
2881
+ for (const link of asArray(i.links)) {
2882
+ if (typeof link !== "object" || link === null) continue;
2883
+ const l = link;
2884
+ const eventCode = normalizeCode(asString(l.eventCode) ?? "");
2885
+ if (!eventCode) continue;
2886
+ const weight = asWeight(l.weight);
2887
+ links.push({ eventCode, ...weight !== void 0 ? { weight } : {} });
2888
+ }
2889
+ return {
2890
+ code,
2891
+ label: asString(i.label),
2892
+ description: asString(i.description),
2893
+ rationale: asString(i.rationale),
2894
+ expectedEffect: asString(i.expectedEffect),
2895
+ confidence: asConfidence(i.confidence),
2896
+ ...links.length ? { links } : {}
2897
+ };
2898
+ }
2899
+ function normalizeSide(value) {
2900
+ switch (value?.toLowerCase()) {
2901
+ case "frontend":
2902
+ return "frontend";
2903
+ case "backend":
2904
+ return "backend";
2905
+ case "either":
2906
+ return "either";
2907
+ default:
2908
+ return void 0;
2909
+ }
2910
+ }
2911
+
2936
2912
  // src/core/verify.ts
2937
2913
  async function pollFirstEvent(config, session, opts = {}) {
2938
2914
  if (config.offline) return { received: false };
@@ -3053,6 +3029,25 @@ function scrubTerminalControls(value) {
3053
3029
  return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
3054
3030
  }
3055
3031
 
3032
+ // src/core/version.ts
3033
+ import { readFileSync } from "fs";
3034
+ import { dirname as dirname2, join as join3, parse } from "path";
3035
+ import { fileURLToPath } from "url";
3036
+ var PACKAGE_NAME = "@whisperr/wizard";
3037
+ function packageVersion() {
3038
+ let dir = dirname2(fileURLToPath(import.meta.url));
3039
+ const { root } = parse(dir);
3040
+ while (true) {
3041
+ try {
3042
+ const pkg = JSON.parse(readFileSync(join3(dir, "package.json"), "utf8"));
3043
+ if (pkg.name === PACKAGE_NAME && pkg.version) return pkg.version;
3044
+ } catch {
3045
+ }
3046
+ if (dir === root) return "0.0.0";
3047
+ dir = dirname2(dir);
3048
+ }
3049
+ }
3050
+
3056
3051
  // src/core/gapreport.ts
3057
3052
  function buildGapReport(input) {
3058
3053
  const outcomes = new Map(
@@ -3280,39 +3275,56 @@ Flutter is live today; ${theme.bright(
3280
3275
  p.log.step(theme.bright(phaseLabel));
3281
3276
  }
3282
3277
  const tick = useIntegrationSpinner ? setInterval(updateIntegrationMessage, 1e3) : void 0;
3278
+ const additions = { proposed: 0, applied: [] };
3279
+ const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
3283
3280
  const outcome = await runIntegrationAgent({
3284
3281
  repoPath,
3285
3282
  config,
3286
3283
  session,
3287
3284
  playbook: chosen.playbook,
3288
3285
  manifest,
3289
- ...process.stdout.isTTY && process.stdin.isTTY ? {
3290
- async onPlanReady(plan) {
3291
- if (useIntegrationSpinner) {
3292
- spin.stop(theme.success("Placement plan ready"));
3293
- }
3294
- p.note(renderPlacementPlan(plan), "Placement plan");
3295
- const placeEntries = plan.filter((entry) => entry.decision === "place");
3296
- let selectedEvents = placeEntries.map((entry) => entry.event);
3297
- if (placeEntries.length) {
3298
- const selection = await p.multiselect({
3299
- message: "Wire these events?",
3300
- options: placeEntries.map((entry) => ({
3301
- value: entry.event,
3302
- label: entry.event,
3303
- hint: `${entry.file ?? "unknown file"}@${entry.anchor ?? "unknown anchor"}`
3304
- })),
3305
- initialValues: selectedEvents,
3306
- required: false
3307
- });
3308
- selectedEvents = p.isCancel(selection) ? [] : selection;
3309
- }
3310
- if (useIntegrationSpinner) {
3311
- spin.start(theme.bright("Continuing integration"));
3312
- }
3313
- return filterEventPlan(plan, selectedEvents);
3286
+ // The plan is always shown — it's the last look at what's about to change
3287
+ // in their code. It only becomes a question under --review-plan; the
3288
+ // default run reads it out and keeps going.
3289
+ async onPlanReady(plan) {
3290
+ if (useIntegrationSpinner) {
3291
+ spin.stop(theme.success("Placement plan ready"));
3314
3292
  }
3315
- } : {},
3293
+ p.note(renderPlacementPlan(plan), "Placement plan");
3294
+ const placeEntries = plan.filter((entry) => entry.decision === "place");
3295
+ let selectedEvents = placeEntries.map((entry) => entry.event);
3296
+ if (config.reviewPlan && interactive && placeEntries.length) {
3297
+ const selection = await p.multiselect({
3298
+ message: "Wire these events?",
3299
+ options: placeEntries.map((entry) => ({
3300
+ value: entry.event,
3301
+ label: entry.event,
3302
+ hint: `${entry.file ?? "unknown file"}@${entry.anchor ?? "unknown anchor"}`
3303
+ })),
3304
+ initialValues: selectedEvents,
3305
+ required: false
3306
+ });
3307
+ selectedEvents = p.isCancel(selection) ? [] : selection;
3308
+ }
3309
+ if (useIntegrationSpinner) {
3310
+ spin.start(theme.bright("Continuing integration"));
3311
+ }
3312
+ return filterEventPlan(plan, selectedEvents);
3313
+ },
3314
+ async onOpportunitiesReady(raw) {
3315
+ if (useIntegrationSpinner) spin.stop(theme.success("Planning done"));
3316
+ const applied = await applyOpportunities({
3317
+ config,
3318
+ session,
3319
+ playbook: chosen.playbook,
3320
+ manifest,
3321
+ fingerprint,
3322
+ raw,
3323
+ additions
3324
+ });
3325
+ if (useIntegrationSpinner) spin.start(theme.bright("Continuing integration"));
3326
+ return applied;
3327
+ },
3316
3328
  progress: {
3317
3329
  onPhase(label) {
3318
3330
  phaseLabel = label;
@@ -3336,13 +3348,78 @@ Flutter is live today; ${theme.bright(
3336
3348
  return null;
3337
3349
  });
3338
3350
  if (tick) clearInterval(tick);
3351
+ let verified = null;
3352
+ let reported = false;
3353
+ const collectReportEvents = async () => {
3354
+ if (!outcome) return [];
3355
+ const eventOutcomeByType = new Map(
3356
+ outcome.eventOutcomes.map((event) => [event.event, event])
3357
+ );
3358
+ const eventTypes = [
3359
+ ...manifest.events.map((e) => e.eventType),
3360
+ ...additions.applied.map((e) => e.code)
3361
+ ];
3362
+ let wired = /* @__PURE__ */ new Map();
3363
+ try {
3364
+ wired = await scanWiredEvents(
3365
+ repoPath,
3366
+ await changedFiles(repoPath, checkpoint),
3367
+ eventTypes
3368
+ );
3369
+ } catch {
3370
+ }
3371
+ return eventTypes.map((eventType) => ({
3372
+ event_type: eventType,
3373
+ status: wired.has(eventType) ? "wired" : "skipped",
3374
+ file: wired.get(eventType),
3375
+ // The agent's own words on why it skipped — the field that answers "why
3376
+ // didn't it wire X" without anyone opening the repo.
3377
+ reason: wired.has(eventType) ? void 0 : eventOutcomeByType.get(eventType)?.reason
3378
+ }));
3379
+ };
3380
+ const reportRun = async (exitClass) => {
3381
+ if (reported) return [];
3382
+ reported = true;
3383
+ const reportEvents2 = await collectReportEvents();
3384
+ const result = await postRunReport(config, session, {
3385
+ target: chosen.playbook.target.id,
3386
+ repo_fingerprint: fingerprint,
3387
+ identify_wired: outcome?.coreOk ?? false,
3388
+ verified,
3389
+ cost_usd: outcome?.costUsd ?? 0,
3390
+ duration_ms: outcome?.durationMs ?? Date.now() - startedAt,
3391
+ summary: scrubSummary(outcome?.summary ?? "").slice(0, 4e3),
3392
+ events: reportEvents2,
3393
+ wizard_version: packageVersion(),
3394
+ planner_model: config.plannerModel,
3395
+ editor_model: config.model,
3396
+ effort: config.effort,
3397
+ exit_class: exitClass,
3398
+ phase_timings: outcome?.phaseTimings ?? [],
3399
+ opportunities_proposed: additions.proposed,
3400
+ opportunities_applied: additions.applied.length
3401
+ });
3402
+ if (!result.ok) {
3403
+ p.log.warn(
3404
+ theme.warn("Couldn't record this run's coverage on the server") + theme.muted(
3405
+ ` (${formatReportFailure(result)}) \u2014 future runs may re-propose events already wired here.`
3406
+ )
3407
+ );
3408
+ }
3409
+ return reportEvents2;
3410
+ };
3339
3411
  if (!outcome) {
3340
3412
  if (useIntegrationSpinner) {
3341
3413
  spin.stop(theme.alert("Integration stopped"));
3342
3414
  } else {
3343
3415
  p.log.error(theme.alert("Integration stopped"));
3344
3416
  }
3345
- await maybeRevert(repoPath, checkpoint, "The run stopped before finishing.");
3417
+ const reverted = await maybeRevert(
3418
+ repoPath,
3419
+ checkpoint,
3420
+ "The run stopped before finishing."
3421
+ );
3422
+ await reportRun(reverted ? "reverted" : "agent_error");
3346
3423
  return 1;
3347
3424
  }
3348
3425
  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");
@@ -3353,7 +3430,6 @@ Flutter is live today; ${theme.bright(
3353
3430
  } else {
3354
3431
  p.log.success(stopLabel);
3355
3432
  }
3356
- const opportunities = await collectOpportunities(repoPath, manifest, checkpoint);
3357
3433
  let files = await changedFiles(repoPath, checkpoint);
3358
3434
  if (files.length) {
3359
3435
  p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
@@ -3366,11 +3442,12 @@ Flutter is live today; ${theme.bright(
3366
3442
  "The core setup didn't complete, so these changes may be incomplete."
3367
3443
  );
3368
3444
  if (reverted) {
3445
+ await reportRun("reverted");
3369
3446
  p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
3370
3447
  return 1;
3371
3448
  }
3449
+ await reportRun("core_failed");
3372
3450
  }
3373
- let verified = null;
3374
3451
  if (chosen.playbook.verifyCommand && files.length) {
3375
3452
  const cmd = chosen.playbook.verifyCommand;
3376
3453
  const vspin = p.spinner();
@@ -3450,94 +3527,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3450
3527
  "The integration didn't pass its build/lint check, so the edits may be broken."
3451
3528
  );
3452
3529
  if (reverted) {
3530
+ await reportRun("reverted");
3453
3531
  p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
3454
3532
  return 1;
3455
3533
  }
3534
+ await reportRun("verify_failed");
3456
3535
  }
3457
3536
  }
3458
3537
  }
3459
- const { acceptedEvents, instrumentationSnapshot } = await offerOpportunities({
3460
- repoPath,
3461
- config,
3462
- session,
3463
- playbook: chosen.playbook,
3464
- manifest,
3465
- opportunities,
3466
- fingerprint,
3467
- checkpoint,
3468
- remainingBudgetUsd: config.budgetUsd - outcome.costUsd,
3469
- repoMap: outcome.repoMap
3470
- });
3471
- if (instrumentationSnapshot && chosen.playbook.verifyCommand) {
3472
- const cmd = chosen.playbook.verifyCommand;
3473
- const preInstrumentationVerified = verified;
3474
- const vspin = p.spinner();
3475
- vspin.start(`Re-verifying after instrumenting the new events \u2014 ${theme.muted(cmd)}`);
3476
- const verdict = await runVerifyCommand(repoPath, cmd);
3477
- verified = verdictToVerified(verdict);
3478
- if (verdict.toolMissing) {
3479
- vspin.stop(
3480
- theme.warn("Couldn't re-verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
3481
- );
3482
- } else if (verdict.timedOut) {
3483
- vspin.stop(
3484
- theme.warn(`Re-verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
3485
- );
3486
- } else if (verdict.ok) {
3487
- vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
3488
- } else {
3489
- vspin.stop(theme.alert(`Verification failed after instrumenting the new events \u2014 ${cmd}`));
3490
- if (verdict.output) {
3491
- p.note(verdict.output, theme.warn("Verifier output"));
3492
- }
3493
- const doRevert = await p.confirm({
3494
- 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.)",
3495
- initialValue: true
3496
- });
3497
- if (!p.isCancel(doRevert) && doRevert) {
3498
- if (await restoreToSnapshot(repoPath, checkpoint, instrumentationSnapshot)) {
3499
- verified = preInstrumentationVerified;
3500
- p.log.success(theme.success("Reverted the instrumentation edits."));
3501
- } else {
3502
- p.log.warn(
3503
- theme.warn("Couldn't revert automatically \u2014 undo manually: ") + (revertHint(checkpoint) ?? "git reset --hard")
3504
- );
3505
- }
3506
- } else {
3507
- p.log.info(
3508
- theme.muted("Left in your working tree \u2014 fix the verifier errors before committing.")
3509
- );
3510
- }
3511
- }
3512
- }
3513
- const filesForScan = acceptedEvents.length ? await changedFiles(repoPath, checkpoint) : files;
3514
- const eventTypesToScan = [
3515
- ...manifest.events.map((e) => e.eventType),
3516
- ...acceptedEvents.map((e) => e.code)
3517
- ];
3518
- const wiredMap = await scanWiredEvents(repoPath, filesForScan, eventTypesToScan);
3519
- const reportEvents = eventTypesToScan.map((eventType) => ({
3520
- event_type: eventType,
3521
- status: wiredMap.has(eventType) ? "wired" : "skipped",
3522
- file: wiredMap.get(eventType)
3523
- }));
3524
- const reportResult = await postRunReport(config, session, {
3525
- target: chosen.playbook.target.id,
3526
- repo_fingerprint: fingerprint,
3527
- identify_wired: outcome.coreOk,
3528
- verified,
3529
- cost_usd: outcome.costUsd,
3530
- duration_ms: outcome.durationMs,
3531
- summary: scrubSummary(outcome.summary).slice(0, 4e3),
3532
- events: reportEvents
3533
- });
3534
- if (!reportResult.ok) {
3535
- p.log.warn(
3536
- theme.warn("Couldn't record this run's coverage on the server") + theme.muted(
3537
- ` (${formatReportFailure(reportResult)}) \u2014 future runs may re-propose events already wired here.`
3538
- )
3539
- );
3540
- }
3538
+ const reportedEvents = await reportRun(
3539
+ outcome.eventsComplete ? "completed" : "events_incomplete"
3540
+ );
3541
+ const reportEvents = reportedEvents.length ? reportedEvents : await collectReportEvents();
3542
+ const wiredMap = new Map(
3543
+ reportEvents.filter((event) => event.status === "wired").map((event) => [event.event_type, event.file ?? ""])
3544
+ );
3541
3545
  try {
3542
3546
  const approvedSuggestions = await approvedSuggestionsPromise;
3543
3547
  if (approvedSuggestions?.length) {
@@ -3577,15 +3581,12 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3577
3581
  if (eventLines) {
3578
3582
  p.note(eventLines, "Events");
3579
3583
  }
3580
- const eventOutcomeByType = new Map(
3581
- outcome.eventOutcomes.map((event) => [event.event, event])
3582
- );
3583
3584
  const gapReport = buildGapReport({
3584
3585
  manifest,
3585
3586
  events: reportEvents.map((event) => ({
3586
3587
  eventType: event.event_type,
3587
3588
  status: event.status,
3588
- reason: eventOutcomeByType.get(event.event_type)?.reason
3589
+ reason: event.reason
3589
3590
  }))
3590
3591
  });
3591
3592
  if (gapReport.hasContent) {
@@ -3594,7 +3595,7 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3594
3595
  theme.signal("What you could be doing")
3595
3596
  );
3596
3597
  }
3597
- const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
3598
+ const eventDenominator = manifest.universeSummary?.wireableHere ?? reportEvents.length;
3598
3599
  const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
3599
3600
  const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
3600
3601
  p.log.info(
@@ -3634,25 +3635,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3634
3635
  p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
3635
3636
  return 0;
3636
3637
  }
3637
- var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
3638
- async function offerOpportunities(opts) {
3639
- const { manifest, config, session } = opts;
3638
+ async function applyOpportunities(opts) {
3639
+ const { config, session, playbook, manifest, fingerprint, raw, additions } = opts;
3640
+ const clean = sanitizeOpportunities(raw, manifest);
3640
3641
  const openSuggestions = await fetchSuggestions(config, session, ["proposed", "approved"]);
3641
- const openSuggestionKeys = new Set(
3642
- openSuggestions.map(
3643
- (suggestion) => `${suggestion.kind}:${normalizeCode(suggestion.code)}`
3644
- )
3642
+ const openKeys = new Set(
3643
+ openSuggestions.map((s) => `${s.kind}:${normalizeCode(s.code)}`)
3645
3644
  );
3646
3645
  const opportunities = {
3647
- events: opts.opportunities.events.filter(
3648
- (event) => !openSuggestionKeys.has(`event:${normalizeCode(event.code)}`)
3649
- ),
3650
- interventions: opts.opportunities.interventions.filter(
3651
- (intervention) => !openSuggestionKeys.has(`intervention:${normalizeCode(intervention.code)}`)
3646
+ events: clean.events.filter((e) => !openKeys.has(`event:${normalizeCode(e.code)}`)),
3647
+ interventions: clean.interventions.filter(
3648
+ (i) => !openKeys.has(`intervention:${normalizeCode(i.code)}`)
3652
3649
  )
3653
3650
  };
3654
- const total = opportunities.events.length + opportunities.interventions.length;
3655
- if (total === 0) return NO_ADDITIONS;
3651
+ additions.proposed = opportunities.events.length + opportunities.interventions.length;
3652
+ if (additions.proposed === 0) return [];
3656
3653
  const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
3657
3654
  p.note(
3658
3655
  [
@@ -3665,110 +3662,29 @@ async function offerOpportunities(opts) {
3665
3662
  );
3666
3663
  if (config.offline) {
3667
3664
  p.log.info(theme.muted("Offline mode \u2014 proposals shown only, nothing submitted."));
3668
- return NO_ADDITIONS;
3669
- }
3670
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
3671
- p.log.info(
3672
- theme.muted("Non-interactive run \u2014 proposals were not submitted. Re-run interactively to add them.")
3673
- );
3674
- return NO_ADDITIONS;
3675
- }
3676
- const selection = await p.multiselect({
3677
- message: "Send these to your dashboard for approval?",
3678
- options: [
3679
- ...opportunities.events.map((e) => ({
3680
- value: `e:${e.code}`,
3681
- label: `event \xB7 ${e.code}`,
3682
- hint: e.description ?? e.rationale
3683
- })),
3684
- ...opportunities.interventions.map((i) => ({
3685
- value: `i:${i.code}`,
3686
- label: `intervention \xB7 ${i.code}`,
3687
- hint: i.description ?? i.rationale
3688
- }))
3689
- ],
3690
- // Opt-in per item: nothing is pre-selected, so submitting a suggestion is
3691
- // an explicit choice rather than an opt-out the user has to notice and undo.
3692
- initialValues: [],
3693
- required: false
3694
- });
3695
- if (p.isCancel(selection) || selection.length === 0) {
3696
- p.log.info(theme.muted("Skipped \u2014 your universe is unchanged."));
3697
- return NO_ADDITIONS;
3665
+ return [];
3698
3666
  }
3699
- const chosenSet = new Set(selection);
3700
- const knownInterventionCodes = new Set(
3701
- manifest.events.flatMap((e) => e.interventions ?? []).map((i) => i.code)
3702
- );
3703
- const knownEventCodes = new Set(manifest.events.map((e) => e.eventType));
3704
- const pickedEvents = opportunities.events.filter((e) => chosenSet.has(`e:${e.code}`));
3705
- const pickedInterventions = opportunities.interventions.filter(
3706
- (i) => chosenSet.has(`i:${i.code}`)
3707
- );
3708
- const pickedInterventionCodes = new Set(pickedInterventions.map((i) => i.code));
3709
- const pickedEventCodes = new Set(pickedEvents.map((e) => e.code));
3710
- const submitted = {
3711
- events: pickedEvents.map((e) => ({
3712
- ...e,
3713
- links: e.links?.filter(
3714
- (l) => knownInterventionCodes.has(l.interventionCode) || pickedInterventionCodes.has(l.interventionCode)
3715
- )
3716
- })),
3717
- interventions: pickedInterventions.map((i) => ({
3718
- ...i,
3719
- links: i.links?.filter(
3720
- (l) => knownEventCodes.has(l.eventCode) || pickedEventCodes.has(l.eventCode)
3721
- )
3722
- }))
3723
- };
3724
- let stageResult;
3725
- try {
3726
- stageResult = await withSpinner(
3727
- "Sending suggestions for approval",
3728
- () => submitSuggestions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
3729
- );
3730
- } catch (err) {
3731
- p.log.warn(
3732
- theme.warn("Couldn't send the proposals") + theme.muted(` \u2014 ${err.message}. Nothing was submitted.`)
3733
- );
3734
- return NO_ADDITIONS;
3735
- }
3736
- if (stageResult) {
3737
- const lines2 = stageResult.outcomes.map((outcome) => {
3738
- const mark = outcome.status === "staged" ? theme.success("\u2713") : outcome.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3739
- const reason = outcome.reason ?? (outcome.duplicateOf && outcome.duplicateOf !== outcome.code ? `already queued as ${outcome.duplicateOf}` : void 0);
3740
- const detail = reason ? theme.muted(` (${reason})`) : "";
3741
- return `${mark} ${outcome.kind} ${theme.bright(outcome.code)}${detail}`;
3742
- });
3743
- lines2.push(
3744
- theme.muted(
3745
- `${stageResult.staged} staged \xB7 ${stageResult.duplicates} already queued \xB7 ${stageResult.invalid} rejected`
3746
- )
3747
- );
3748
- p.note(lines2.join("\n"), theme.signal("Suggestions submitted"));
3749
- const where = stageResult.approvalsUrl ? `approve at ${theme.bright(stageResult.approvalsUrl)}` : "approve them in your Whisperr dashboard";
3750
- p.log.info(
3751
- theme.muted(
3752
- `${stageResult.staged} proposal${stageResult.staged === 1 ? "" : "s"} sent \u2014 ${where}. The next wizard run wires whatever you approve.`
3753
- )
3754
- );
3755
- return NO_ADDITIONS;
3667
+ if (config.proposeOnly) {
3668
+ await stageOpportunities(config, session, playbook, fingerprint, opportunities);
3669
+ return [];
3756
3670
  }
3757
3671
  let result;
3758
3672
  try {
3759
3673
  result = await withSpinner(
3760
3674
  "Adding to your universe",
3761
- () => submitAdditions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
3675
+ () => submitAdditions(config, session, playbook.target.id, fingerprint, opportunities)
3762
3676
  );
3763
3677
  } catch (err) {
3764
3678
  p.log.warn(
3765
3679
  theme.warn("Couldn't add the proposals") + theme.muted(` \u2014 ${err.message}. Your universe is unchanged.`)
3766
3680
  );
3767
- return NO_ADDITIONS;
3681
+ return [];
3768
3682
  }
3769
3683
  const lines = result.outcomes.map((o) => {
3770
3684
  const mark = o.status === "applied" ? theme.success("\u2713") : o.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3771
- 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})`) : "";
3685
+ const detail = o.status === "duplicate" ? theme.muted(
3686
+ ` (already in your universe${o.duplicateOf && o.duplicateOf !== o.code ? ` as ${o.duplicateOf}` : ""})`
3687
+ ) : o.status === "invalid" && o.reason ? theme.muted(` (${o.reason})`) : "";
3772
3688
  return `${mark} ${o.kind} ${theme.bright(o.code)}${detail}`;
3773
3689
  });
3774
3690
  lines.push(
@@ -3776,7 +3692,7 @@ async function offerOpportunities(opts) {
3776
3692
  `${result.applied} added \xB7 ${result.duplicates} already present \xB7 ${result.invalid} rejected`
3777
3693
  )
3778
3694
  );
3779
- if (submitted.interventions.length) {
3695
+ if (opportunities.interventions.length) {
3780
3696
  lines.push(theme.muted("New interventions start paused \u2014 activate them in your dashboard."));
3781
3697
  }
3782
3698
  if (result.policyRegen?.status === "pending") {
@@ -3799,41 +3715,49 @@ async function offerOpportunities(opts) {
3799
3715
  )
3800
3716
  );
3801
3717
  }
3802
- const appliedEventCodes = new Set(
3718
+ const appliedCodes = new Set(
3803
3719
  result.outcomes.filter((o) => o.kind === "event" && o.status === "applied").map((o) => o.code)
3804
3720
  );
3805
- const acceptedEvents = pickedEvents.filter((e) => appliedEventCodes.has(e.code));
3806
- if (!acceptedEvents.length) return NO_ADDITIONS;
3807
- const prePass = await snapshotChanges(opts.repoPath, opts.checkpoint);
3808
- const spin = p.spinner();
3809
- spin.start(theme.bright("Instrumenting the newly added events"));
3721
+ const applied = opportunities.events.filter((e) => appliedCodes.has(e.code));
3722
+ additions.applied = applied;
3723
+ return applied;
3724
+ }
3725
+ async function stageOpportunities(config, session, playbook, fingerprint, opportunities) {
3726
+ let stageResult;
3810
3727
  try {
3811
- const pass = await runAdditionsInstrumentationPass({
3812
- repoPath: opts.repoPath,
3813
- config,
3814
- session,
3815
- playbook: opts.playbook,
3816
- acceptedEvents,
3817
- budgetUsd: opts.remainingBudgetUsd,
3818
- repoMap: opts.repoMap
3819
- });
3820
- if (pass.ran) {
3821
- spin.stop(theme.success("New events instrumented"));
3822
- logAgentSummary(pass.summary);
3823
- } else {
3824
- spin.stop(
3825
- theme.warn("Skipped instrumenting the new events") + theme.muted(
3826
- " \u2014 the spend limit was reached. They're in your universe; wire them on a future run."
3827
- )
3828
- );
3829
- }
3728
+ stageResult = await withSpinner(
3729
+ "Sending suggestions for approval",
3730
+ () => submitSuggestions(config, session, playbook.target.id, fingerprint, opportunities)
3731
+ );
3830
3732
  } catch (err) {
3831
- spin.stop(
3832
- theme.warn("Couldn't instrument the new events") + theme.muted(` \u2014 ${err.message}. They're in your universe; wire them on a future run.`)
3733
+ p.log.warn(
3734
+ theme.warn("Couldn't send the proposals") + theme.muted(` \u2014 ${err.message}. Nothing was submitted.`)
3833
3735
  );
3736
+ return;
3834
3737
  }
3835
- const edited = !snapshotsEqual(prePass, await snapshotChanges(opts.repoPath, opts.checkpoint));
3836
- return { acceptedEvents, instrumentationSnapshot: edited ? prePass : null };
3738
+ if (!stageResult) {
3739
+ p.log.warn(
3740
+ theme.warn("This Whisperr backend has no approval queue") + theme.muted(" \u2014 nothing was submitted. Re-run without --propose-only to add them directly.")
3741
+ );
3742
+ return;
3743
+ }
3744
+ const lines = stageResult.outcomes.map((outcome) => {
3745
+ const mark = outcome.status === "staged" ? theme.success("\u2713") : outcome.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3746
+ const reason = outcome.reason ?? (outcome.duplicateOf && outcome.duplicateOf !== outcome.code ? `already queued as ${outcome.duplicateOf}` : void 0);
3747
+ return `${mark} ${outcome.kind} ${theme.bright(outcome.code)}${reason ? theme.muted(` (${reason})`) : ""}`;
3748
+ });
3749
+ lines.push(
3750
+ theme.muted(
3751
+ `${stageResult.staged} staged \xB7 ${stageResult.duplicates} already queued \xB7 ${stageResult.invalid} rejected`
3752
+ )
3753
+ );
3754
+ p.note(lines.join("\n"), theme.signal("Suggestions submitted"));
3755
+ const where = stageResult.approvalsUrl ? `approve at ${theme.bright(stageResult.approvalsUrl)}` : "approve them in your Whisperr dashboard";
3756
+ p.log.info(
3757
+ theme.muted(
3758
+ `${stageResult.staged} proposal${stageResult.staged === 1 ? "" : "s"} sent \u2014 ${where}. The next wizard run wires whatever you approve.`
3759
+ )
3760
+ );
3837
3761
  }
3838
3762
  async function maybeRevert(repoPath, checkpoint, reason) {
3839
3763
  if (!checkpoint.isRepo) return false;
@@ -3993,12 +3917,10 @@ function logAgentSummary(summary) {
3993
3917
  }
3994
3918
  function shortPhaseLabel(phase) {
3995
3919
  switch (phase) {
3996
- case "Mapping your codebase":
3997
- return "map";
3920
+ case "Planning your integration":
3921
+ return "plan";
3998
3922
  case "Installing the SDK & wiring identify()":
3999
3923
  return "core";
4000
- case "Planning event placements":
4001
- return "plan";
4002
3924
  case "Instrumenting planned events":
4003
3925
  case "Instrumenting your events":
4004
3926
  return "wire";
@@ -4031,12 +3953,7 @@ function formatReportFailure(result) {
4031
3953
  }
4032
3954
 
4033
3955
  // src/index.ts
4034
- var modulePath = fileURLToPath(import.meta.url);
4035
- function packageVersion() {
4036
- const packagePath = join4(dirname2(modulePath), "..", "package.json");
4037
- const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
4038
- return pkg.version ?? "0.0.0";
4039
- }
3956
+ var modulePath = fileURLToPath2(import.meta.url);
4040
3957
  function parseArgs(argv) {
4041
3958
  const args = [...argv];
4042
3959
  const opts = {};
@@ -4056,6 +3973,15 @@ function parseArgs(argv) {
4056
3973
  case "--force":
4057
3974
  opts.force = true;
4058
3975
  break;
3976
+ case "--review-plan":
3977
+ opts.reviewPlan = true;
3978
+ break;
3979
+ case "--propose-only":
3980
+ opts.proposeOnly = true;
3981
+ break;
3982
+ case "--strict-review":
3983
+ opts.strictReview = true;
3984
+ break;
4059
3985
  case "--api":
4060
3986
  opts.apiBaseUrl = args[++i];
4061
3987
  break;
@@ -4082,6 +4008,10 @@ function printHelp() {
4082
4008
  ` ${theme.bright("Options")}`,
4083
4009
  " --offline Use a demo manifest, no account/browser needed",
4084
4010
  " --force Proceed without a clean git tree (no safe undo)",
4011
+ " --review-plan Confirm the placement plan before anything is wired",
4012
+ " --propose-only Send new events/interventions for dashboard approval",
4013
+ " instead of adding them to your universe now",
4014
+ " --strict-review Review the finished diff with the deeper planner model",
4085
4015
  " --api <url> Override the Whisperr API base URL",
4086
4016
  " --model <id> Override the coding-agent model",
4087
4017
  " -h, --help Show this help",