@whisperr/wizard 0.2.0 → 0.3.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 +48 -10
  2. package/dist/index.js +1719 -194
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,16 +2,17 @@
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
9
- import { resolve } from "path";
9
+ import { resolve as resolve2 } from "path";
10
10
  import * as p from "@clack/prompts";
11
11
 
12
12
  // src/core/config.ts
13
13
  var DEFAULT_API_BASE = "https://api.whisperr.net";
14
14
  var DEFAULT_MODEL = "claude-sonnet-5";
15
+ var DEFAULT_PLANNER_MODEL = "claude-opus-4-8";
15
16
  var DEFAULT_EFFORT = "high";
16
17
  var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
17
18
  function resolveEffort() {
@@ -29,6 +30,7 @@ function resolveConfig(flags = {}) {
29
30
  apiBaseUrl,
30
31
  llmBaseUrl,
31
32
  model: flags.model ?? process.env.WHISPERR_WIZARD_MODEL ?? DEFAULT_MODEL,
33
+ plannerModel: process.env.WHISPERR_WIZARD_PLANNER_MODEL ?? DEFAULT_PLANNER_MODEL,
32
34
  effort: resolveEffort(),
33
35
  // Cost is the real limiter (budgetUsd below). maxTurns is only a high safety
34
36
  // backstop against a stuck loop — it should NOT bind on a normal run. (It
@@ -36,7 +38,7 @@ function resolveConfig(flags = {}) {
36
38
  maxTurns: Number(process.env.WHISPERR_WIZARD_MAX_TURNS) || 200,
37
39
  // Hard ceiling on TOTAL spend across all phases. If hit, the run stops
38
40
  // cleanly and keeps whatever already landed. Real runs finish well under it.
39
- budgetUsd: Number(process.env.WHISPERR_WIZARD_BUDGET_USD) || 10,
41
+ budgetUsd: Number(process.env.WHISPERR_WIZARD_BUDGET_USD) || 25,
40
42
  directAnthropicKey,
41
43
  offline
42
44
  };
@@ -1148,6 +1150,355 @@ function mockManifest(appId, config) {
1148
1150
  // src/core/agent.ts
1149
1151
  import { query } from "@anthropic-ai/claude-agent-sdk";
1150
1152
 
1153
+ // src/core/git.ts
1154
+ import { spawn } from "child_process";
1155
+ import { createHash } from "crypto";
1156
+ import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
1157
+ import { basename, dirname, join as join2 } from "path";
1158
+ async function takeCheckpoint(repoPath) {
1159
+ const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1160
+ if (!isRepo) return { isRepo: false };
1161
+ const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1162
+ const head = await run(repoPath, ["rev-parse", "HEAD"]);
1163
+ return {
1164
+ isRepo: true,
1165
+ baseRef: head.ok ? head.stdout.trim() : void 0,
1166
+ branch: branch || void 0
1167
+ };
1168
+ }
1169
+ async function changedFiles(repoPath, checkpoint) {
1170
+ if (!checkpoint.isRepo) return [];
1171
+ const tracked = await run(repoPath, ["diff", "--name-only"]);
1172
+ const untracked = await run(repoPath, [
1173
+ "ls-files",
1174
+ "--others",
1175
+ "--exclude-standard"
1176
+ ]);
1177
+ const set = /* @__PURE__ */ new Set();
1178
+ for (const f of `${tracked.stdout}
1179
+ ${untracked.stdout}`.split("\n")) {
1180
+ if (f.trim()) set.add(f.trim());
1181
+ }
1182
+ return [...set];
1183
+ }
1184
+ function revertHint(checkpoint) {
1185
+ if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1186
+ return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1187
+ }
1188
+ async function wasTrackedAtCheckpoint(repoPath, checkpoint, file) {
1189
+ if (!checkpoint.isRepo) return false;
1190
+ if (checkpoint.baseRef) {
1191
+ const res2 = await run(repoPath, [
1192
+ "ls-tree",
1193
+ "--name-only",
1194
+ checkpoint.baseRef,
1195
+ "--",
1196
+ file
1197
+ ]);
1198
+ return res2.ok && res2.stdout.trim() !== "";
1199
+ }
1200
+ const res = await run(repoPath, ["ls-files", "--", file]);
1201
+ return res.ok && res.stdout.trim() !== "";
1202
+ }
1203
+ async function isWorkingTreeClean(repoPath) {
1204
+ const status = await run(repoPath, ["status", "--porcelain"]);
1205
+ return status.ok && status.stdout.trim() === "";
1206
+ }
1207
+ async function revertToCheckpoint(repoPath, checkpoint) {
1208
+ if (!checkpoint.isRepo) return false;
1209
+ if (checkpoint.baseRef) {
1210
+ const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
1211
+ const clean2 = await run(repoPath, ["clean", "-fd"]);
1212
+ return reset.ok && clean2.ok;
1213
+ }
1214
+ const restore = await run(repoPath, ["restore", "."]);
1215
+ const clean = await run(repoPath, ["clean", "-fd"]);
1216
+ return restore.ok && clean.ok;
1217
+ }
1218
+ async function snapshotChanges(repoPath, checkpoint) {
1219
+ const snapshot = /* @__PURE__ */ new Map();
1220
+ for (const file of await changedFiles(repoPath, checkpoint)) {
1221
+ try {
1222
+ snapshot.set(file, await readFile2(join2(repoPath, file)));
1223
+ } catch {
1224
+ snapshot.set(file, null);
1225
+ }
1226
+ }
1227
+ return snapshot;
1228
+ }
1229
+ function snapshotsEqual(a, b) {
1230
+ if (a.size !== b.size) return false;
1231
+ for (const [file, content] of a) {
1232
+ const other = b.get(file);
1233
+ if (other === void 0) return false;
1234
+ if (content === null || other === null) {
1235
+ if (content !== other) return false;
1236
+ } else if (!content.equals(other)) {
1237
+ return false;
1238
+ }
1239
+ }
1240
+ return true;
1241
+ }
1242
+ async function restoreToSnapshot(repoPath, checkpoint, snapshot) {
1243
+ try {
1244
+ const current = await changedFiles(repoPath, checkpoint);
1245
+ for (const file of /* @__PURE__ */ new Set([...current, ...snapshot.keys()])) {
1246
+ const recorded = snapshot.get(file);
1247
+ const path = join2(repoPath, file);
1248
+ if (recorded === void 0) {
1249
+ if (checkpoint.baseRef) {
1250
+ const co = await run(repoPath, ["checkout", checkpoint.baseRef, "--", file]);
1251
+ if (co.ok) continue;
1252
+ }
1253
+ await rm(path, { force: true });
1254
+ } else if (recorded === null) {
1255
+ await rm(path, { force: true });
1256
+ } else {
1257
+ await mkdir(dirname(path), { recursive: true });
1258
+ await writeFile(path, recorded);
1259
+ }
1260
+ }
1261
+ return true;
1262
+ } catch {
1263
+ return false;
1264
+ }
1265
+ }
1266
+ async function repoFingerprint(repoPath) {
1267
+ const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1268
+ const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
1269
+ return createHash("sha256").update(seed).digest("hex").slice(0, 16);
1270
+ }
1271
+ function normalizeRemote(url) {
1272
+ return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1273
+ }
1274
+ async function scanWiredEvents(repoPath, files, eventTypes) {
1275
+ const wired = /* @__PURE__ */ new Map();
1276
+ const patterns = eventTypes.map((e) => ({
1277
+ eventType: e,
1278
+ trackRe: new RegExp(
1279
+ `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
1280
+ ),
1281
+ literalRe: quotedLiteralRegExp(e)
1282
+ }));
1283
+ const contents = [];
1284
+ for (const file of files) {
1285
+ try {
1286
+ contents.push({ file, content: await readFile2(join2(repoPath, file), "utf8") });
1287
+ } catch {
1288
+ continue;
1289
+ }
1290
+ }
1291
+ for (const { file, content } of contents) {
1292
+ for (const { eventType, trackRe } of patterns) {
1293
+ if (!wired.has(eventType) && trackRe.test(content)) {
1294
+ wired.set(eventType, file);
1295
+ }
1296
+ }
1297
+ }
1298
+ for (const { file, content } of contents) {
1299
+ for (const { eventType, literalRe } of patterns) {
1300
+ if (!wired.has(eventType) && literalRe.test(content)) {
1301
+ wired.set(eventType, file);
1302
+ }
1303
+ }
1304
+ }
1305
+ return wired;
1306
+ }
1307
+ function quotedLiteralRegExp(eventType) {
1308
+ const leftBoundary = /^\w/.test(eventType) ? "\\b" : "";
1309
+ const rightBoundary = /\w$/.test(eventType) ? "\\b" : "";
1310
+ return new RegExp(`(['"\`])${leftBoundary}${escapeRegExp(eventType)}${rightBoundary}\\1`);
1311
+ }
1312
+ function escapeRegExp(s) {
1313
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1314
+ }
1315
+ function run(cwd, args) {
1316
+ return new Promise((resolve3) => {
1317
+ const child = spawn("git", args, { cwd });
1318
+ let stdout = "";
1319
+ let stderr = "";
1320
+ child.stdout.on("data", (d) => stdout += d.toString());
1321
+ child.stderr.on("data", (d) => stderr += d.toString());
1322
+ child.on("close", (code) => resolve3({ ok: code === 0, stdout, stderr }));
1323
+ child.on("error", () => resolve3({ ok: false, stdout, stderr }));
1324
+ });
1325
+ }
1326
+
1327
+ // src/core/opportunities.ts
1328
+ import { readFile as readFile3, rm as rm2 } from "fs/promises";
1329
+ import { join as join3 } from "path";
1330
+ var OPPORTUNITIES_FILE = "whisperr-opportunities.json";
1331
+ function normalizeCode(value) {
1332
+ return value.trim().toLowerCase().replace(/[ \-./]/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
1333
+ }
1334
+ async function collectOpportunities(repoPath, manifest, checkpoint) {
1335
+ const path = join3(repoPath, OPPORTUNITIES_FILE);
1336
+ if (await wasTrackedAtCheckpoint(repoPath, checkpoint, OPPORTUNITIES_FILE)) {
1337
+ return { events: [], interventions: [] };
1338
+ }
1339
+ let rawText;
1340
+ try {
1341
+ rawText = await readFile3(path, "utf8");
1342
+ } catch {
1343
+ return { events: [], interventions: [] };
1344
+ }
1345
+ await rm2(path, { force: true }).catch(() => {
1346
+ });
1347
+ let raw;
1348
+ try {
1349
+ raw = JSON.parse(rawText);
1350
+ } catch {
1351
+ return { events: [], interventions: [] };
1352
+ }
1353
+ return sanitizeOpportunities(raw, manifest);
1354
+ }
1355
+ function sanitizeOpportunities(raw, manifest) {
1356
+ const out = { events: [], interventions: [] };
1357
+ if (typeof raw !== "object" || raw === null) return out;
1358
+ const doc = raw;
1359
+ const knownEvents = new Set(manifest.events.map((e) => normalizeCode(e.eventType)));
1360
+ const knownInterventions = /* @__PURE__ */ new Set([
1361
+ ...manifest.events.flatMap((e) => e.interventions ?? []).map((i) => normalizeCode(i.code)),
1362
+ ...(manifest.interventions ?? []).map((i) => normalizeCode(i.code))
1363
+ ]);
1364
+ const seenEvents = /* @__PURE__ */ new Set();
1365
+ for (const entry of asArray(doc.events)) {
1366
+ const ev = coerceEvent(entry);
1367
+ if (!ev) continue;
1368
+ if (knownEvents.has(ev.code) || seenEvents.has(ev.code)) continue;
1369
+ seenEvents.add(ev.code);
1370
+ out.events.push(ev);
1371
+ }
1372
+ const seenInterventions = /* @__PURE__ */ new Set();
1373
+ for (const entry of asArray(doc.interventions)) {
1374
+ const iv = coerceIntervention(entry);
1375
+ if (!iv) continue;
1376
+ if (knownInterventions.has(iv.code) || seenInterventions.has(iv.code)) continue;
1377
+ seenInterventions.add(iv.code);
1378
+ out.interventions.push(iv);
1379
+ }
1380
+ return out;
1381
+ }
1382
+ var SUBMIT_TIMEOUT_MS = 3e4;
1383
+ async function submitAdditions(config, session, target9, repoFingerprint2, opportunities) {
1384
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/additions`, {
1385
+ method: "POST",
1386
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1387
+ headers: {
1388
+ "Content-Type": "application/json",
1389
+ Authorization: `Bearer ${session.token}`
1390
+ },
1391
+ body: JSON.stringify({
1392
+ target: target9,
1393
+ repoFingerprint: repoFingerprint2,
1394
+ events: opportunities.events,
1395
+ interventions: opportunities.interventions
1396
+ })
1397
+ });
1398
+ if (!res.ok) {
1399
+ const body = await res.text().catch(() => "");
1400
+ throw new Error(
1401
+ `Submitting universe additions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1402
+ );
1403
+ }
1404
+ return await res.json();
1405
+ }
1406
+ function asArray(value) {
1407
+ return Array.isArray(value) ? value : [];
1408
+ }
1409
+ function asString(value) {
1410
+ if (typeof value !== "string") return void 0;
1411
+ const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
1412
+ return cleaned ? cleaned : void 0;
1413
+ }
1414
+ function asConfidence(value) {
1415
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1416
+ return Math.min(1, Math.max(0, value));
1417
+ }
1418
+ function asWeight(value) {
1419
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1420
+ if (value < 0 || value > 1) return void 0;
1421
+ return value;
1422
+ }
1423
+ function coerceEvent(entry) {
1424
+ if (typeof entry !== "object" || entry === null) return null;
1425
+ const e = entry;
1426
+ const code = normalizeCode(asString(e.code) ?? "");
1427
+ if (!code) return null;
1428
+ const properties = [];
1429
+ for (const prop of asArray(e.properties)) {
1430
+ if (typeof prop === "string") {
1431
+ if (prop.trim()) properties.push({ name: prop.trim() });
1432
+ continue;
1433
+ }
1434
+ if (typeof prop !== "object" || prop === null) continue;
1435
+ const pr = prop;
1436
+ const name = asString(pr.name);
1437
+ if (!name) continue;
1438
+ const desc = asString(pr.description);
1439
+ properties.push({
1440
+ name,
1441
+ ...desc ? { description: desc } : {},
1442
+ ...pr.required === true ? { required: true } : {}
1443
+ });
1444
+ }
1445
+ const links = [];
1446
+ for (const link of asArray(e.links)) {
1447
+ if (typeof link !== "object" || link === null) continue;
1448
+ const l = link;
1449
+ const interventionCode = normalizeCode(asString(l.interventionCode) ?? "");
1450
+ if (!interventionCode) continue;
1451
+ const weight = asWeight(l.weight);
1452
+ links.push({ interventionCode, ...weight !== void 0 ? { weight } : {} });
1453
+ }
1454
+ const side = normalizeSide(asString(e.side));
1455
+ return {
1456
+ code,
1457
+ label: asString(e.label),
1458
+ description: asString(e.description),
1459
+ ...side ? { side } : {},
1460
+ rationale: asString(e.rationale),
1461
+ confidence: asConfidence(e.confidence),
1462
+ ...properties.length ? { properties } : {},
1463
+ ...links.length ? { links } : {}
1464
+ };
1465
+ }
1466
+ function coerceIntervention(entry) {
1467
+ if (typeof entry !== "object" || entry === null) return null;
1468
+ const i = entry;
1469
+ const code = normalizeCode(asString(i.code) ?? "");
1470
+ if (!code) return null;
1471
+ const links = [];
1472
+ for (const link of asArray(i.links)) {
1473
+ if (typeof link !== "object" || link === null) continue;
1474
+ const l = link;
1475
+ const eventCode = normalizeCode(asString(l.eventCode) ?? "");
1476
+ if (!eventCode) continue;
1477
+ const weight = asWeight(l.weight);
1478
+ links.push({ eventCode, ...weight !== void 0 ? { weight } : {} });
1479
+ }
1480
+ return {
1481
+ code,
1482
+ label: asString(i.label),
1483
+ description: asString(i.description),
1484
+ rationale: asString(i.rationale),
1485
+ confidence: asConfidence(i.confidence),
1486
+ ...links.length ? { links } : {}
1487
+ };
1488
+ }
1489
+ function normalizeSide(value) {
1490
+ switch (value?.toLowerCase()) {
1491
+ case "frontend":
1492
+ return "frontend";
1493
+ case "backend":
1494
+ return "backend";
1495
+ case "either":
1496
+ return "either";
1497
+ default:
1498
+ return void 0;
1499
+ }
1500
+ }
1501
+
1151
1502
  // src/core/playbooks/shared-prompt.ts
1152
1503
  var BASE_WIZARD_PROMPT = `
1153
1504
  You are the Whisperr Wizard \u2014 an expert integration agent running INSIDE a
@@ -1324,6 +1675,46 @@ function renderEventsBrief(m) {
1324
1675
  }
1325
1676
  return lines.join("\n");
1326
1677
  }
1678
+ function renderOpportunitiesBrief(m, opportunitiesFile) {
1679
+ const eventCodes = m.events.map((e) => e.eventType);
1680
+ const interventionCodes = [
1681
+ ...new Set(m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code))
1682
+ ];
1683
+ return [
1684
+ "UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
1685
+ "While auditing you read this app's real lifecycle. If you found churn-",
1686
+ "relevant moments the plan does NOT cover \u2014 e.g. a recurring-payment or",
1687
+ "cancellation path with no event, a support/refund flow worth a retention",
1688
+ "play \u2014 record them as PROPOSALS. Do NOT add track() calls for them.",
1689
+ `Write a single JSON file at the repo root named ${opportunitiesFile}:`,
1690
+ "",
1691
+ "{",
1692
+ ' "events": [{',
1693
+ ' "code": "snake_case_event", "label": "...", "description": "...",',
1694
+ ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1695
+ ' "confidence": 0.0-1.0,',
1696
+ ' "properties": [{"name": "...", "description": "...", "required": false}],',
1697
+ ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1698
+ " }],",
1699
+ ' "interventions": [{',
1700
+ ' "code": "snake_case_strategy", "label": "...", "description": "...",',
1701
+ ' "rationale": "...", "confidence": 0.0-1.0,',
1702
+ ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1703
+ " }]",
1704
+ "}",
1705
+ "",
1706
+ "Rules:",
1707
+ `- The plan already covers these events: ${eventCodes.join(", ") || "(none)"}.`,
1708
+ ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1709
+ " Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
1710
+ "- Every proposal needs concrete code evidence in its rationale (file/flow),",
1711
+ " not speculation. 0-3 events and 0-2 interventions is the normal range;",
1712
+ " an empty file or no file at all is a perfectly good outcome.",
1713
+ "- Link each proposed event to the intervention(s) it should feed (existing",
1714
+ " codes or ones you propose in the same file).",
1715
+ "- This file is metadata for the wizard, not app code \u2014 write it and move on."
1716
+ ].join("\n");
1717
+ }
1327
1718
  function coverageNote(coverage) {
1328
1719
  if (!coverage?.length) return "";
1329
1720
  const here = coverage.find((c) => c.sameSurface && c.status === "wired");
@@ -1335,15 +1726,319 @@ function coverageNote(coverage) {
1335
1726
  return "";
1336
1727
  }
1337
1728
 
1729
+ // src/core/toolPolicy.ts
1730
+ import { isAbsolute, relative, resolve } from "path";
1731
+ var SECRET_MATERIAL_DENIAL = "blocked: secret material - reference the variable name in .env.example instead of reading the real file";
1732
+ var WRITE_OUTSIDE_REPO_DENIAL = "blocked: writes must stay inside the target repository";
1733
+ var BASH_ALLOWLIST_DENIAL = "blocked: command is outside the Bash allowlist - use package install/add commands, mkdir, or git status/diff/log only";
1734
+ var CHAINED_COMMAND_DENIAL = "blocked: chained or substituted shell command - run one command at a time";
1735
+ var ENV_EXAMPLES = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
1736
+ var SECRET_DIRECTORIES = /* @__PURE__ */ new Set([".aws", ".ssh", ".gnupg"]);
1737
+ var SECRET_EXACT_FILES = /* @__PURE__ */ new Set([".netrc", ".npmrc", ".pypirc"]);
1738
+ var SECRET_SUFFIXES = [
1739
+ ".pem",
1740
+ ".key",
1741
+ ".p12",
1742
+ ".pfx",
1743
+ ".jks",
1744
+ ".keystore",
1745
+ ".tfvars"
1746
+ ];
1747
+ var JS_PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun"]);
1748
+ var PYTHON_PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["pip", "pip3", "poetry", "uv"]);
1749
+ var RUBY_PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["gem", "bundle"]);
1750
+ function evaluateToolUse(toolName, input, context) {
1751
+ switch (toolName) {
1752
+ case "Read":
1753
+ return evaluateReadLike(readPathInputs(input), readPathInputs(input), context);
1754
+ case "Grep":
1755
+ return evaluateReadLike(grepGlobPathInputs(input), grepPathInputs(input), context);
1756
+ case "Glob":
1757
+ return evaluateReadLike(grepGlobPathInputs(input), grepGlobPathInputs(input), context);
1758
+ case "Bash":
1759
+ return evaluateBash(input);
1760
+ case "Write":
1761
+ case "Edit":
1762
+ return evaluateWrite(input, context);
1763
+ default:
1764
+ return {
1765
+ behavior: "deny",
1766
+ message: `blocked: tool ${toolName} is not allowed by the wizard tool policy`
1767
+ };
1768
+ }
1769
+ }
1770
+ function isSecretPathLike(value) {
1771
+ const parts = normalizePathLike(value).split("/").filter(Boolean);
1772
+ for (const rawPart of parts) {
1773
+ const part = stripOuterQuotes(rawPart.trim().toLowerCase());
1774
+ if (!part || part === "." || part === "**") continue;
1775
+ if (ENV_EXAMPLES.has(part)) continue;
1776
+ if (SECRET_DIRECTORIES.has(part)) return true;
1777
+ if (SECRET_EXACT_FILES.has(part)) return true;
1778
+ if (part === ".env" || part === ".envrc" || part.startsWith(".env.") || part.startsWith(".env*")) {
1779
+ return true;
1780
+ }
1781
+ if (part.startsWith("id_rsa") || part.startsWith("id_ecdsa") || part.startsWith("id_ed25519")) {
1782
+ return true;
1783
+ }
1784
+ if (part.includes("credentials")) return true;
1785
+ if (part.startsWith("secrets.")) return true;
1786
+ if (SECRET_SUFFIXES.some((suffix) => part.endsWith(suffix))) return true;
1787
+ }
1788
+ return false;
1789
+ }
1790
+ function isPathInsideRepo(pathValue, repoPath) {
1791
+ if (pathValue.includes("..")) return false;
1792
+ const repoRoot = resolve(repoPath);
1793
+ const candidate = isAbsolute(pathValue) ? resolve(pathValue) : resolve(repoRoot, pathValue);
1794
+ const rel = relative(repoRoot, candidate);
1795
+ return rel === "" || !!rel && !rel.startsWith("..") && !isAbsolute(rel);
1796
+ }
1797
+ function evaluateReadLike(secretValues, repoPathValues, context) {
1798
+ for (const value of secretValues) {
1799
+ if (isSecretPathLike(value)) {
1800
+ return { behavior: "deny", message: SECRET_MATERIAL_DENIAL };
1801
+ }
1802
+ }
1803
+ for (const value of repoPathValues) {
1804
+ if (!isPathInsideRepo(value, context.repoPath)) {
1805
+ return {
1806
+ behavior: "deny",
1807
+ message: "blocked: reads must stay inside the target repository"
1808
+ };
1809
+ }
1810
+ }
1811
+ return { behavior: "allow" };
1812
+ }
1813
+ function evaluateWrite(input, context) {
1814
+ const pathValue = firstString(input, ["file_path", "path"]);
1815
+ if (!pathValue) {
1816
+ return { behavior: "deny", message: "blocked: write target path is missing" };
1817
+ }
1818
+ if (!isPathInsideRepo(pathValue, context.repoPath)) {
1819
+ return { behavior: "deny", message: WRITE_OUTSIDE_REPO_DENIAL };
1820
+ }
1821
+ return { behavior: "allow" };
1822
+ }
1823
+ function evaluateBash(input) {
1824
+ const command = firstString(input, ["command"]);
1825
+ if (!command) {
1826
+ return { behavior: "deny", message: "blocked: Bash command is missing" };
1827
+ }
1828
+ const split = splitShellSegments(command);
1829
+ if (split.hasCommandSubstitution) {
1830
+ return { behavior: "deny", message: CHAINED_COMMAND_DENIAL };
1831
+ }
1832
+ const segmentDecisions = split.segments.map(evaluateBashSegment);
1833
+ const denied = segmentDecisions.find((decision) => decision.behavior === "deny");
1834
+ if (denied) {
1835
+ return split.hadChain ? { behavior: "deny", message: CHAINED_COMMAND_DENIAL } : denied;
1836
+ }
1837
+ return { behavior: "allow" };
1838
+ }
1839
+ function evaluateBashSegment(segment) {
1840
+ const tokens = tokenizeShellSegment(segment);
1841
+ if (!tokens.length) return { behavior: "deny", message: BASH_ALLOWLIST_DENIAL };
1842
+ const command = tokens[0]?.toLowerCase() ?? "";
1843
+ const second = tokens[1]?.toLowerCase() ?? "";
1844
+ const third = tokens[2]?.toLowerCase() ?? "";
1845
+ if (JS_PACKAGE_MANAGERS.has(command)) {
1846
+ if (second === "install" || second === "add" || second === "ci") {
1847
+ return { behavior: "allow" };
1848
+ }
1849
+ if (second === "pkg" && (third === "get" || third === "set")) {
1850
+ return { behavior: "allow" };
1851
+ }
1852
+ }
1853
+ if (PYTHON_PACKAGE_MANAGERS.has(command)) {
1854
+ if (second === "install" || second === "add") {
1855
+ return { behavior: "allow" };
1856
+ }
1857
+ if (command === "uv" && second === "pip" && third === "install") {
1858
+ return { behavior: "allow" };
1859
+ }
1860
+ }
1861
+ if (command === "composer" && (second === "install" || second === "require")) {
1862
+ return { behavior: "allow" };
1863
+ }
1864
+ if (command === "pod" && second === "install") {
1865
+ return { behavior: "allow" };
1866
+ }
1867
+ if ((command === "flutter" || command === "dart") && second === "pub") {
1868
+ return { behavior: "allow" };
1869
+ }
1870
+ if (RUBY_PACKAGE_MANAGERS.has(command) && (second === "install" || second === "add")) {
1871
+ return { behavior: "allow" };
1872
+ }
1873
+ if (command === "go" && (second === "get" || second === "mod")) {
1874
+ return { behavior: "allow" };
1875
+ }
1876
+ if (command === "mkdir") {
1877
+ return { behavior: "allow" };
1878
+ }
1879
+ if (command === "git" && (second === "status" || second === "diff" || second === "log")) {
1880
+ if (tokens.slice(2).some((token) => isSecretPathLike(token))) {
1881
+ return { behavior: "deny", message: SECRET_MATERIAL_DENIAL };
1882
+ }
1883
+ return { behavior: "allow" };
1884
+ }
1885
+ return { behavior: "deny", message: BASH_ALLOWLIST_DENIAL };
1886
+ }
1887
+ function readPathInputs(input) {
1888
+ return stringInputs(input, ["file_path", "path"]);
1889
+ }
1890
+ function grepGlobPathInputs(input) {
1891
+ return stringInputs(input, ["file_path", "path", "pattern", "glob"]);
1892
+ }
1893
+ function grepPathInputs(input) {
1894
+ return stringInputs(input, ["file_path", "path"]);
1895
+ }
1896
+ function stringInputs(input, keys) {
1897
+ const values = [];
1898
+ for (const key of keys) {
1899
+ const value = input[key];
1900
+ if (typeof value === "string" && value.trim()) values.push(value.trim());
1901
+ }
1902
+ return values;
1903
+ }
1904
+ function firstString(input, keys) {
1905
+ return stringInputs(input, keys)[0];
1906
+ }
1907
+ function normalizePathLike(value) {
1908
+ return stripOuterQuotes(value.trim()).replace(/\\/g, "/");
1909
+ }
1910
+ function stripOuterQuotes(value) {
1911
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1912
+ return value.slice(1, -1);
1913
+ }
1914
+ return value;
1915
+ }
1916
+ function splitShellSegments(command) {
1917
+ const segments = [];
1918
+ let current = "";
1919
+ let quote;
1920
+ let escaped = false;
1921
+ let hadChain = false;
1922
+ let hasCommandSubstitution = false;
1923
+ const push = () => {
1924
+ const segment = current.trim();
1925
+ if (segment) segments.push(segment);
1926
+ current = "";
1927
+ };
1928
+ for (let i = 0; i < command.length; i++) {
1929
+ const ch = command[i];
1930
+ const next = command[i + 1];
1931
+ if (ch === "`" || ch === "$" && next === "(") {
1932
+ hasCommandSubstitution = true;
1933
+ }
1934
+ if (escaped) {
1935
+ current += ch;
1936
+ escaped = false;
1937
+ continue;
1938
+ }
1939
+ if (ch === "\\" && quote !== "'") {
1940
+ current += ch;
1941
+ escaped = true;
1942
+ continue;
1943
+ }
1944
+ if (quote) {
1945
+ if (ch === quote) quote = void 0;
1946
+ current += ch;
1947
+ continue;
1948
+ }
1949
+ if (ch === '"' || ch === "'") {
1950
+ quote = ch;
1951
+ current += ch;
1952
+ continue;
1953
+ }
1954
+ const two = `${ch}${next ?? ""}`;
1955
+ if (two === "&&" || two === "||") {
1956
+ hadChain = true;
1957
+ push();
1958
+ i++;
1959
+ continue;
1960
+ }
1961
+ if (ch === ";" || ch === "|" || ch === "\n") {
1962
+ hadChain = true;
1963
+ push();
1964
+ continue;
1965
+ }
1966
+ current += ch;
1967
+ }
1968
+ push();
1969
+ return { segments, hadChain, hasCommandSubstitution };
1970
+ }
1971
+ function tokenizeShellSegment(segment) {
1972
+ const tokens = [];
1973
+ let current = "";
1974
+ let quote;
1975
+ let escaped = false;
1976
+ const push = () => {
1977
+ if (current) tokens.push(current);
1978
+ current = "";
1979
+ };
1980
+ for (let i = 0; i < segment.length; i++) {
1981
+ const ch = segment[i];
1982
+ if (escaped) {
1983
+ current += ch;
1984
+ escaped = false;
1985
+ continue;
1986
+ }
1987
+ if (ch === "\\" && quote !== "'") {
1988
+ escaped = true;
1989
+ continue;
1990
+ }
1991
+ if (quote) {
1992
+ if (ch === quote) quote = void 0;
1993
+ else current += ch;
1994
+ continue;
1995
+ }
1996
+ if (ch === '"' || ch === "'") {
1997
+ quote = ch;
1998
+ continue;
1999
+ }
2000
+ if (/\s/.test(ch)) {
2001
+ push();
2002
+ continue;
2003
+ }
2004
+ current += ch;
2005
+ }
2006
+ push();
2007
+ return tokens;
2008
+ }
2009
+
1338
2010
  // src/core/agent.ts
1339
2011
  async function runIntegrationAgent(opts) {
1340
- const { repoPath, config, session, playbook, manifest, progress } = opts;
2012
+ const { repoPath, config, session, playbook, manifest, progress, onPlanReady } = opts;
1341
2013
  applyModelAuthEnv(config, session);
1342
2014
  const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
1343
2015
  const started = Date.now();
1344
2016
  let costUsd = 0;
1345
2017
  const summaries = [];
1346
- progress?.onPhase?.("Installing the SDK & wiring identify()");
2018
+ let repoMap = "";
2019
+ let eventOutcomes = [];
2020
+ const phaseTimings = [];
2021
+ const BUDGET_FLOOR = 0.25;
2022
+ if (config.budgetUsd - costUsd > BUDGET_FLOOR) {
2023
+ try {
2024
+ const map = await runTimedPass("Mapping your codebase", {
2025
+ prompt: renderRepoMapPrompt(repoPath),
2026
+ systemPrompt: systemPrompt9,
2027
+ repoPath,
2028
+ model: config.plannerModel,
2029
+ effort: "high",
2030
+ maxTurns: 40,
2031
+ budgetUsd: Math.min(config.budgetUsd - costUsd, config.budgetUsd * 0.15),
2032
+ allowedTools: READ_ONLY_TOOLS,
2033
+ progress
2034
+ }, phaseTimings);
2035
+ costUsd += map.costUsd;
2036
+ repoMap = sliceRepoMap(map.summary);
2037
+ } catch (err) {
2038
+ debugNote(`repo map pass failed: ${err.message}`);
2039
+ repoMap = "";
2040
+ }
2041
+ }
1347
2042
  const corePrompt = [
1348
2043
  `Integrate the Whisperr ${playbook.target.displayName} SDK \u2014 CORE SETUP ONLY.`,
1349
2044
  `Project root: ${repoPath}`,
@@ -1368,11 +2063,12 @@ async function runIntegrationAgent(opts) {
1368
2063
  "Do NOT instrument product events yet \u2014 that is a separate step. Do not run",
1369
2064
  "the analyzer or build; the human will review the diff.",
1370
2065
  "",
2066
+ ...renderRepoMapSection(repoMap),
1371
2067
  "----- IDENTIFY -----",
1372
2068
  renderIdentifyBrief(manifest),
1373
2069
  "----- END -----"
1374
2070
  ].join("\n");
1375
- const core = await runPass({
2071
+ const core = await runTimedPass("Installing the SDK & wiring identify()", {
1376
2072
  prompt: corePrompt,
1377
2073
  systemPrompt: systemPrompt9,
1378
2074
  repoPath,
@@ -1381,58 +2077,111 @@ async function runIntegrationAgent(opts) {
1381
2077
  maxTurns: 50,
1382
2078
  budgetUsd: config.budgetUsd - costUsd,
1383
2079
  progress
1384
- });
2080
+ }, phaseTimings);
1385
2081
  costUsd += core.costUsd;
1386
2082
  if (core.summary) summaries.push(`Core setup:
1387
2083
  ${core.summary}`);
1388
- const BUDGET_FLOOR = 0.25;
1389
2084
  let eventsComplete = true;
1390
2085
  if (manifest.events.length > 0 && config.budgetUsd - costUsd <= BUDGET_FLOOR) {
1391
2086
  eventsComplete = false;
2087
+ eventOutcomes = manifest.events.map((event) => ({
2088
+ event: event.eventType,
2089
+ outcome: "skipped",
2090
+ reason: "Spend limit reached during core setup."
2091
+ }));
1392
2092
  summaries.push(
1393
2093
  "Events: skipped \u2014 the spend limit was reached during core setup. Re-run with a higher WHISPERR_WIZARD_BUDGET_USD to instrument events."
1394
2094
  );
1395
2095
  } else if (manifest.events.length > 0) {
1396
- progress?.onPhase?.("Instrumenting your events");
1397
- const eventsPrompt = [
1398
- `The Whisperr ${playbook.target.displayName} SDK is already installed and`,
1399
- "initialized, and identify() is wired. Now instrument the product events",
1400
- "below with track().",
1401
- "",
1402
- "Work in importance order. Place each event at the real call site where the",
1403
- "END USER performs the action (not an admin/staff path \u2014 see WHO COUNTS AS",
1404
- `"THE USER"), and attach the properties you can source from what's in scope`,
1405
- "there (see the property-capture rule). These events describe the same",
1406
- "person identify() did, so they live on the same end-user surface. Skip fast",
1407
- "when an event has no clear trigger here \u2014 spend steps placing, not exploring.",
1408
- "Stop once the events that clearly exist in this app are wired.",
1409
- "",
1410
- "----- EVENTS -----",
1411
- renderEventsBrief(manifest),
1412
- "----- END -----"
1413
- ].join("\n");
1414
- const events = await runPass({
1415
- prompt: eventsPrompt,
2096
+ const planPass = await runTimedPass("Planning event placements", {
2097
+ prompt: renderEventPlanPrompt(repoPath, playbook, manifest, repoMap),
1416
2098
  systemPrompt: systemPrompt9,
1417
2099
  repoPath,
1418
- model: config.model,
1419
- effort: config.effort,
1420
- maxTurns: config.maxTurns,
2100
+ model: config.plannerModel,
2101
+ effort: "high",
2102
+ maxTurns: 40,
1421
2103
  budgetUsd: config.budgetUsd - costUsd,
2104
+ allowedTools: READ_ONLY_TOOLS,
1422
2105
  progress
1423
- });
1424
- costUsd += events.costUsd;
1425
- eventsComplete = !events.maxedOut;
1426
- if (events.summary) summaries.push(`Events:
1427
- ${events.summary}`);
2106
+ }, phaseTimings);
2107
+ costUsd += planPass.costUsd;
2108
+ const plan = parseEventPlan(planPass.summary);
2109
+ if (!plan) {
2110
+ debugNote("event plan parse failed; falling back to direct event wiring");
2111
+ progress?.onActivity?.("Event plan was not parseable; falling back to direct wiring");
2112
+ const direct = await runDirectEventsPass({
2113
+ repoPath,
2114
+ config,
2115
+ systemPrompt: systemPrompt9,
2116
+ playbook,
2117
+ manifest,
2118
+ repoMap,
2119
+ budgetUsd: config.budgetUsd - costUsd,
2120
+ progress,
2121
+ phaseTimings
2122
+ });
2123
+ costUsd += direct.pass.costUsd;
2124
+ eventsComplete = !direct.pass.maxedOut;
2125
+ eventOutcomes = direct.eventOutcomes;
2126
+ if (direct.pass.summary) summaries.push(`Events:
2127
+ ${direct.pass.summary}`);
2128
+ } else {
2129
+ let scopedPlan = planForManifest(plan, manifest);
2130
+ const reviewedPlan = await onPlanReady?.(scopedPlan);
2131
+ if (reviewedPlan !== void 0 && reviewedPlan !== null) {
2132
+ scopedPlan = planForManifest(reviewedPlan, manifest);
2133
+ }
2134
+ const placeEntries = scopedPlan.filter((entry) => entry.decision === "place");
2135
+ const unsureEntries = scopedPlan.filter((entry) => entry.decision === "unsure");
2136
+ let wire;
2137
+ if (placeEntries.length && config.budgetUsd - costUsd > BUDGET_FLOOR) {
2138
+ wire = await runTimedPass("Instrumenting planned events", {
2139
+ prompt: renderEventWirePrompt(repoPath, playbook, repoMap, placeEntries),
2140
+ systemPrompt: systemPrompt9,
2141
+ repoPath,
2142
+ model: config.model,
2143
+ effort: "high",
2144
+ maxTurns: config.maxTurns,
2145
+ budgetUsd: config.budgetUsd - costUsd,
2146
+ progress
2147
+ }, phaseTimings);
2148
+ costUsd += wire.costUsd;
2149
+ if (wire.summary) summaries.push(`Events:
2150
+ ${wire.summary}`);
2151
+ } else if (!placeEntries.length) {
2152
+ summaries.push("Events: no events had a confident placement in this surface.");
2153
+ }
2154
+ let wired = await scanManifestEvents(repoPath, manifest);
2155
+ const missedPlaceEntries = placeEntries.filter((entry) => !wired.has(entry.event));
2156
+ const followUpEntries = uniquePlanEntries([...missedPlaceEntries, ...unsureEntries]);
2157
+ let followUp;
2158
+ if (followUpEntries.length && config.budgetUsd - costUsd > BUDGET_FLOOR) {
2159
+ followUp = await runTimedPass("Reconciling missed events", {
2160
+ prompt: renderEventReconcilePrompt(repoPath, playbook, repoMap, followUpEntries),
2161
+ systemPrompt: systemPrompt9,
2162
+ repoPath,
2163
+ model: config.plannerModel,
2164
+ effort: "medium",
2165
+ maxTurns: 15,
2166
+ budgetUsd: config.budgetUsd - costUsd,
2167
+ progress
2168
+ }, phaseTimings);
2169
+ costUsd += followUp.costUsd;
2170
+ if (followUp.summary) summaries.push(`Event reconciliation:
2171
+ ${followUp.summary}`);
2172
+ wired = await scanManifestEvents(repoPath, manifest);
2173
+ }
2174
+ eventOutcomes = buildEventOutcomes(manifest, scopedPlan, wired);
2175
+ eventsComplete = !planPass.maxedOut && !(wire?.maxedOut ?? false) && !(followUp?.maxedOut ?? false);
2176
+ }
1428
2177
  }
1429
2178
  if (core.ok && config.budgetUsd - costUsd > BUDGET_FLOOR) {
1430
- progress?.onPhase?.("Reviewing & correcting placements");
1431
2179
  const reviewPrompt = [
1432
2180
  `You wired the Whisperr ${playbook.target.displayName} SDK into this repo.`,
1433
2181
  "Now AUDIT YOUR OWN WORK and fix mistakes before finishing.",
1434
2182
  `Project root: ${repoPath}`,
1435
2183
  "",
2184
+ ...renderRepoMapSection(repoMap),
1436
2185
  "Run `git diff` and read the surrounding code to see exactly what you added.",
1437
2186
  "For every identify() and track() call, verify \u2014 and FIX in place:",
1438
2187
  "1. Subject \u2014 keys to the END USER, never an admin/staff/operator/seller.",
@@ -1448,41 +2197,469 @@ ${events.summary}`);
1448
2197
  "4. Coverage \u2014 every gateway/callback path that should emit an event does;",
1449
2198
  " no recurring or secondary path left as a dead zone.",
1450
2199
  "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.'."
2200
+ "calls or re-explore the whole repo. Be surgical.",
2201
+ "",
2202
+ renderOpportunitiesBrief(manifest, OPPORTUNITIES_FILE),
2203
+ "",
2204
+ "End with a one-line, plain-text note of what you corrected (or 'No",
2205
+ "corrections needed.'), plus one line on what you proposed, if anything."
1453
2206
  ].join("\n");
1454
- const review = await runPass({
2207
+ const review = await runTimedPass("Reviewing & correcting placements", {
1455
2208
  prompt: reviewPrompt,
1456
2209
  systemPrompt: systemPrompt9,
1457
2210
  repoPath,
1458
- model: config.model,
1459
- effort: config.effort,
2211
+ model: config.plannerModel,
2212
+ effort: "medium",
1460
2213
  maxTurns: 40,
1461
2214
  budgetUsd: config.budgetUsd - costUsd,
1462
2215
  progress
1463
- });
2216
+ }, phaseTimings);
1464
2217
  costUsd += review.costUsd;
1465
2218
  if (review.summary) summaries.push(`Review:
1466
2219
  ${review.summary}`);
2220
+ if (manifest.events.length) {
2221
+ eventOutcomes = refreshWiredOutcomes(
2222
+ eventOutcomes,
2223
+ manifest,
2224
+ await scanManifestEvents(repoPath, manifest)
2225
+ );
2226
+ }
1467
2227
  }
1468
2228
  return {
1469
- summary: summaries.join("\n\n"),
1470
- costUsd,
1471
- durationMs: Date.now() - started,
1472
- coreOk: core.ok,
1473
- eventsComplete
2229
+ summary: summaries.join("\n\n"),
2230
+ costUsd,
2231
+ durationMs: Date.now() - started,
2232
+ phaseTimings,
2233
+ coreOk: core.ok,
2234
+ eventsComplete,
2235
+ eventOutcomes,
2236
+ repoMap: repoMap || void 0
2237
+ };
2238
+ }
2239
+ async function runAdditionsInstrumentationPass(opts) {
2240
+ const { repoPath, config, session, playbook, acceptedEvents, budgetUsd, repoMap, progress } = opts;
2241
+ if (!acceptedEvents.length || budgetUsd <= 0) {
2242
+ return { summary: "", costUsd: 0, ran: false };
2243
+ }
2244
+ applyModelAuthEnv(config, session);
2245
+ const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
2246
+ const eventLines = [];
2247
+ for (const e of acceptedEvents) {
2248
+ eventLines.push("");
2249
+ eventLines.push(`\u25A0 ${e.code}${e.label ? ` (${e.label})` : ""}`);
2250
+ if (e.description) eventLines.push(` ${e.description}`);
2251
+ if (e.rationale) eventLines.push(` where: ${e.rationale}`);
2252
+ if (e.properties?.length) {
2253
+ eventLines.push(" properties to capture if available:");
2254
+ for (const prop of e.properties) {
2255
+ eventLines.push(` \xB7 ${prop.name}${prop.required ? " (required)" : ""}${prop.description ? ` \u2014 ${prop.description}` : ""}`);
2256
+ }
2257
+ }
2258
+ }
2259
+ progress?.onPhase?.("Instrumenting the newly added events");
2260
+ const prompt = [
2261
+ "You proposed these events as universe opportunities while reviewing this",
2262
+ "repo, and the user just APPROVED adding them to the plan. Instrument them",
2263
+ "now with track(), exactly like the plan's other events: place each at the",
2264
+ "real call site (your own rationale tells you where you saw it), attach the",
2265
+ "properties available in scope, and skip anything that turns out not to have",
2266
+ "a clear trigger after all. The SDK is already installed and initialized.",
2267
+ `Project root: ${repoPath}`,
2268
+ "",
2269
+ ...renderRepoMapSection(repoMap),
2270
+ "----- APPROVED NEW EVENTS -----",
2271
+ ...eventLines,
2272
+ "",
2273
+ "----- END -----"
2274
+ ].join("\n");
2275
+ const pass = await runPass({
2276
+ prompt,
2277
+ systemPrompt: systemPrompt9,
2278
+ repoPath,
2279
+ model: config.model,
2280
+ effort: config.effort,
2281
+ maxTurns: 25,
2282
+ budgetUsd,
2283
+ progress
2284
+ });
2285
+ return { summary: pass.summary, costUsd: pass.costUsd, ran: true };
2286
+ }
2287
+ async function runRepairPass(opts) {
2288
+ const {
2289
+ repoPath,
2290
+ config,
2291
+ session,
2292
+ playbook,
2293
+ verifyCommand,
2294
+ verifyOutput,
2295
+ budgetUsd,
2296
+ progress
2297
+ } = opts;
2298
+ if (budgetUsd <= 0) return { summary: "", costUsd: 0, ran: false };
2299
+ applyModelAuthEnv(config, session);
2300
+ const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
2301
+ progress?.onPhase?.("Repairing verifier failures");
2302
+ const prompt = [
2303
+ "The Whisperr integration was applied, but the verifier command failed.",
2304
+ `Project root: ${repoPath}`,
2305
+ "",
2306
+ "Verifier command:",
2307
+ verifyCommand,
2308
+ "",
2309
+ "Verifier output tail:",
2310
+ tail(verifyOutput, 4e3) || "(no output)",
2311
+ "",
2312
+ "Fix ONLY what the verifier reports; do not refactor. Keep the existing",
2313
+ "Whisperr placement intent unless a verifier error directly requires a small",
2314
+ "syntax/import/type correction. Do not explore unrelated code."
2315
+ ].join("\n");
2316
+ const pass = await runPass({
2317
+ prompt,
2318
+ systemPrompt: systemPrompt9,
2319
+ repoPath,
2320
+ model: config.model,
2321
+ effort: "high",
2322
+ maxTurns: 20,
2323
+ budgetUsd,
2324
+ progress
2325
+ });
2326
+ return { summary: pass.summary, costUsd: pass.costUsd, ran: true };
2327
+ }
2328
+ var READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
2329
+ var FULL_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
2330
+ function parseEventPlan(text) {
2331
+ const parsed = parseFirstJsonArray(text);
2332
+ if (!Array.isArray(parsed)) return null;
2333
+ const entries = [];
2334
+ for (const item of parsed) {
2335
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2336
+ const obj = item;
2337
+ const event = typeof obj.event === "string" ? obj.event.trim() : "";
2338
+ const decision = obj.decision;
2339
+ if (!event || decision !== "place" && decision !== "skip" && decision !== "unsure") {
2340
+ return null;
2341
+ }
2342
+ const entry = {
2343
+ event,
2344
+ decision,
2345
+ reason: typeof obj.reason === "string" ? obj.reason.trim() : ""
2346
+ };
2347
+ if (typeof obj.file === "string" && obj.file.trim()) {
2348
+ entry.file = obj.file.trim();
2349
+ }
2350
+ if (typeof obj.anchor === "string" && obj.anchor.trim()) {
2351
+ entry.anchor = obj.anchor.trim();
2352
+ }
2353
+ if (Array.isArray(obj.properties)) {
2354
+ entry.properties = obj.properties.filter((prop) => typeof prop === "string" && !!prop.trim()).map((prop) => prop.trim());
2355
+ }
2356
+ entries.push(entry);
2357
+ }
2358
+ return entries;
2359
+ }
2360
+ function parseFirstJsonArray(text) {
2361
+ const start = text.indexOf("[");
2362
+ if (start < 0) return null;
2363
+ const json = sliceJsonArray(text, start);
2364
+ if (!json) return null;
2365
+ try {
2366
+ return JSON.parse(json);
2367
+ } catch {
2368
+ return null;
2369
+ }
2370
+ }
2371
+ function sliceJsonArray(text, start) {
2372
+ let depth = 0;
2373
+ let inString = false;
2374
+ let escaped = false;
2375
+ for (let i = start; i < text.length; i++) {
2376
+ const ch = text[i];
2377
+ if (inString) {
2378
+ if (escaped) {
2379
+ escaped = false;
2380
+ } else if (ch === "\\") {
2381
+ escaped = true;
2382
+ } else if (ch === '"') {
2383
+ inString = false;
2384
+ }
2385
+ continue;
2386
+ }
2387
+ if (ch === '"') {
2388
+ inString = true;
2389
+ } else if (ch === "[") {
2390
+ depth++;
2391
+ } else if (ch === "]") {
2392
+ depth--;
2393
+ if (depth === 0) return text.slice(start, i + 1);
2394
+ }
2395
+ }
2396
+ return null;
2397
+ }
2398
+ function renderRepoMapPrompt(repoPath) {
2399
+ return [
2400
+ "Map this repository for a Whisperr SDK integration. This is READ-ONLY.",
2401
+ `Project root: ${repoPath}`,
2402
+ "",
2403
+ "Produce a concise structured repo map as final text with these exact sections:",
2404
+ "- framework + entry points",
2405
+ "- auth flows enumerated: END-USER vs admin/staff, with precise file paths",
2406
+ "- billing/payment/webhook surfaces",
2407
+ "- analytics/tracking wrapper if any",
2408
+ "- state management",
2409
+ "- directory guide: which dirs hold pages/controllers/services",
2410
+ "",
2411
+ "This map guides event placement. List file paths precisely. No prose beyond the map."
2412
+ ].join("\n");
2413
+ }
2414
+ function sliceRepoMap(map) {
2415
+ return map.trim().slice(0, 8e3);
2416
+ }
2417
+ function renderRepoMapSection(repoMap) {
2418
+ const map = repoMap?.trim();
2419
+ if (!map) return [];
2420
+ return ["----- REPO MAP -----", map, "----- END REPO MAP -----", ""];
2421
+ }
2422
+ function renderEventPlanPrompt(repoPath, playbook, manifest, repoMap) {
2423
+ return [
2424
+ `Plan Whisperr ${playbook.target.displayName} event placements. This is READ-ONLY.`,
2425
+ `Project root: ${repoPath}`,
2426
+ "",
2427
+ ...renderRepoMapSection(repoMap),
2428
+ "Use the repo map and the events brief to decide where each event belongs.",
2429
+ "Return ONLY a JSON code block containing an array with this shape:",
2430
+ '[{"event":"event_name","decision":"place|skip|unsure","file":"path","anchor":"function/route/handler","properties":["prop"],"reason":"short reason"}]',
2431
+ "",
2432
+ "Rules:",
2433
+ "- decision=place only when there is a concrete end-user call site in this repo.",
2434
+ "- decision=skip when the event clearly lives on another surface or does not exist here.",
2435
+ "- decision=unsure when there is a plausible surface but the exact anchor is not proven.",
2436
+ "- For place decisions, include file and anchor precisely.",
2437
+ "- properties should list only properties likely available at that anchor.",
2438
+ "- No prose beyond the JSON code block.",
2439
+ "",
2440
+ "----- EVENTS -----",
2441
+ renderEventsBrief(manifest),
2442
+ "----- END EVENTS -----"
2443
+ ].join("\n");
2444
+ }
2445
+ function renderEventWirePrompt(repoPath, playbook, repoMap, entries) {
2446
+ return [
2447
+ `The Whisperr ${playbook.target.displayName} SDK is installed and initialized.`,
2448
+ "Wire only the planned event placements below with track().",
2449
+ `Project root: ${repoPath}`,
2450
+ "",
2451
+ ...renderRepoMapSection(repoMap),
2452
+ "For each: open the file, find the anchor, add track() with the listed",
2453
+ "properties. If an anchor is genuinely absent, mark it unsure in your",
2454
+ "summary and move on. Do not re-plan skipped or unsure events in this pass.",
2455
+ "",
2456
+ "----- EVENT WIRING PLAN -----",
2457
+ ...renderPlanEntryLines(entries),
2458
+ "----- END EVENT WIRING PLAN -----"
2459
+ ].join("\n");
2460
+ }
2461
+ function renderEventReconcilePrompt(repoPath, playbook, repoMap, entries) {
2462
+ return [
2463
+ `One targeted follow-up for Whisperr ${playbook.target.displayName} events.`,
2464
+ `Project root: ${repoPath}`,
2465
+ "",
2466
+ ...renderRepoMapSection(repoMap),
2467
+ "The events below were either planned for placement but not detected in the",
2468
+ "changed files, or were marked unsure. For each one, make one focused attempt",
2469
+ "at the listed file/anchor. If the anchor is absent or still not clear, skip",
2470
+ "it and say why in the summary. Do not search broadly or refactor.",
2471
+ "",
2472
+ "----- TARGET EVENTS -----",
2473
+ ...renderPlanEntryLines(entries),
2474
+ "----- END TARGET EVENTS -----"
2475
+ ].join("\n");
2476
+ }
2477
+ function renderPlanEntryLines(entries) {
2478
+ const lines = [];
2479
+ for (const entry of entries) {
2480
+ lines.push("");
2481
+ lines.push(`- event: ${entry.event}`);
2482
+ lines.push(` decision: ${entry.decision}`);
2483
+ if (entry.file) lines.push(` file: ${entry.file}`);
2484
+ if (entry.anchor) lines.push(` anchor: ${entry.anchor}`);
2485
+ if (entry.properties?.length) {
2486
+ lines.push(` properties: ${entry.properties.join(", ")}`);
2487
+ }
2488
+ if (entry.reason) lines.push(` reason: ${entry.reason}`);
2489
+ }
2490
+ return lines;
2491
+ }
2492
+ async function runDirectEventsPass(opts) {
2493
+ const {
2494
+ repoPath,
2495
+ config,
2496
+ systemPrompt: systemPrompt9,
2497
+ playbook,
2498
+ manifest,
2499
+ repoMap,
2500
+ budgetUsd,
2501
+ progress,
2502
+ phaseTimings
2503
+ } = opts;
2504
+ const eventsPrompt = [
2505
+ `The Whisperr ${playbook.target.displayName} SDK is already installed and`,
2506
+ "initialized, and identify() is wired. Now instrument the product events",
2507
+ "below with track().",
2508
+ "",
2509
+ ...renderRepoMapSection(repoMap),
2510
+ "Work in importance order. Place each event at the real call site where the",
2511
+ "END USER performs the action (not an admin/staff path \u2014 see WHO COUNTS AS",
2512
+ `"THE USER"), and attach the properties you can source from what's in scope`,
2513
+ "there (see the property-capture rule). These events describe the same",
2514
+ "person identify() did, so they live on the same end-user surface. Skip fast",
2515
+ "when an event has no clear trigger here \u2014 spend steps placing, not exploring.",
2516
+ "Stop once the events that clearly exist in this app are wired.",
2517
+ "",
2518
+ "----- EVENTS -----",
2519
+ renderEventsBrief(manifest),
2520
+ "----- END -----"
2521
+ ].join("\n");
2522
+ const pass = await runTimedPass("Instrumenting your events", {
2523
+ prompt: eventsPrompt,
2524
+ systemPrompt: systemPrompt9,
2525
+ repoPath,
2526
+ model: config.model,
2527
+ effort: config.effort,
2528
+ maxTurns: config.maxTurns,
2529
+ budgetUsd,
2530
+ progress
2531
+ }, phaseTimings);
2532
+ const wired = await scanManifestEvents(repoPath, manifest);
2533
+ return {
2534
+ pass,
2535
+ eventOutcomes: manifest.events.map(
2536
+ (event) => wired.has(event.eventType) ? { event: event.eventType, outcome: "wired" } : {
2537
+ event: event.eventType,
2538
+ outcome: "skipped",
2539
+ reason: "No track() call was detected after the direct wiring pass."
2540
+ }
2541
+ )
1474
2542
  };
1475
2543
  }
2544
+ function planForManifest(plan, manifest) {
2545
+ const known = new Set(manifest.events.map((event) => event.eventType));
2546
+ return uniquePlanEntries(plan.filter((entry) => known.has(entry.event)));
2547
+ }
2548
+ function uniquePlanEntries(entries) {
2549
+ const seen = /* @__PURE__ */ new Set();
2550
+ const unique = [];
2551
+ for (const entry of entries) {
2552
+ if (seen.has(entry.event)) continue;
2553
+ seen.add(entry.event);
2554
+ unique.push(entry);
2555
+ }
2556
+ return unique;
2557
+ }
2558
+ async function scanManifestEvents(repoPath, manifest) {
2559
+ return scanEvents(repoPath, manifest.events.map((event) => event.eventType));
2560
+ }
2561
+ async function scanEvents(repoPath, eventTypes) {
2562
+ let files = [];
2563
+ try {
2564
+ files = await changedFiles(repoPath, { isRepo: true });
2565
+ } catch {
2566
+ return /* @__PURE__ */ new Map();
2567
+ }
2568
+ if (!files.length) return /* @__PURE__ */ new Map();
2569
+ return scanWiredEvents(repoPath, files, eventTypes);
2570
+ }
2571
+ function buildEventOutcomes(manifest, plan, wired) {
2572
+ const byEvent = new Map(plan.map((entry) => [entry.event, entry]));
2573
+ return manifest.events.map((event) => {
2574
+ if (wired.has(event.eventType)) {
2575
+ return { event: event.eventType, outcome: "wired" };
2576
+ }
2577
+ const entry = byEvent.get(event.eventType);
2578
+ if (entry?.decision === "skip") {
2579
+ return {
2580
+ event: event.eventType,
2581
+ outcome: "skipped",
2582
+ reason: entry.reason || "Planner skipped this event for this surface."
2583
+ };
2584
+ }
2585
+ if (entry?.decision === "unsure") {
2586
+ return {
2587
+ event: event.eventType,
2588
+ outcome: "skipped",
2589
+ reason: entry.reason || "Planner could not confirm a concrete anchor."
2590
+ };
2591
+ }
2592
+ if (entry?.decision === "place") {
2593
+ return {
2594
+ event: event.eventType,
2595
+ outcome: "skipped",
2596
+ reason: "No track() call was detected after wiring and one targeted follow-up."
2597
+ };
2598
+ }
2599
+ return {
2600
+ event: event.eventType,
2601
+ outcome: "skipped",
2602
+ reason: "No placement decision was returned."
2603
+ };
2604
+ });
2605
+ }
2606
+ function refreshWiredOutcomes(current, manifest, wired) {
2607
+ const byEvent = new Map(current.map((outcome) => [outcome.event, outcome]));
2608
+ return manifest.events.map((event) => {
2609
+ if (wired.has(event.eventType)) {
2610
+ return { event: event.eventType, outcome: "wired" };
2611
+ }
2612
+ return byEvent.get(event.eventType) ?? {
2613
+ event: event.eventType,
2614
+ outcome: "skipped",
2615
+ reason: "No track() call was detected."
2616
+ };
2617
+ });
2618
+ }
2619
+ function tail(s, max) {
2620
+ const trimmed = s.trim();
2621
+ return trimmed.length > max ? trimmed.slice(-max) : trimmed;
2622
+ }
2623
+ function debugNote(message) {
2624
+ if (process.env.WHISPERR_WIZARD_DEBUG) {
2625
+ console.error(`[whisperr-wizard] ${message}`);
2626
+ }
2627
+ }
2628
+ async function runTimedPass(phase, opts, phaseTimings) {
2629
+ opts.progress?.onPhase?.(phase);
2630
+ const started = Date.now();
2631
+ try {
2632
+ return await runPass(opts);
2633
+ } finally {
2634
+ phaseTimings.push({ phase, ms: Date.now() - started });
2635
+ }
2636
+ }
1476
2637
  function isLimitStop(err) {
1477
2638
  const msg = err?.message ?? "";
1478
2639
  return /max(imum)?[\s_-]*(turn|budget)|budget.*exceeded|number of turns/i.test(msg);
1479
2640
  }
1480
2641
  async function runPass(opts) {
1481
- const { prompt, systemPrompt: systemPrompt9, repoPath, model, effort, maxTurns, budgetUsd, progress } = opts;
2642
+ const {
2643
+ prompt,
2644
+ systemPrompt: systemPrompt9,
2645
+ repoPath,
2646
+ model,
2647
+ effort,
2648
+ maxTurns,
2649
+ budgetUsd,
2650
+ allowedTools = FULL_TOOLS,
2651
+ progress
2652
+ } = opts;
1482
2653
  let summary = "";
1483
2654
  let costUsd = 0;
1484
2655
  let ok = false;
1485
2656
  let maxedOut = false;
2657
+ let lastActivityLine = "";
2658
+ const emitActivity = (line) => {
2659
+ if (!line || line === lastActivityLine) return;
2660
+ lastActivityLine = line;
2661
+ progress?.onActivity?.(line);
2662
+ };
1486
2663
  const response = query({
1487
2664
  prompt,
1488
2665
  options: {
@@ -1494,8 +2671,8 @@ async function runPass(opts) {
1494
2671
  // defaults so the behavior is pinned regardless of SDK version.
1495
2672
  thinking: { type: "adaptive" },
1496
2673
  effort,
1497
- allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
1498
- permissionMode: "bypassPermissions",
2674
+ tools: [...allowedTools],
2675
+ canUseTool: createToolPermissionCallback(repoPath),
1499
2676
  maxTurns,
1500
2677
  // Native SDK hard cost cap. Stops this pass with an `error_max_budget_usd`
1501
2678
  // result once spend exceeds the remaining budget — this, not maxTurns, is
@@ -1509,9 +2686,9 @@ async function runPass(opts) {
1509
2686
  if (message.type === "assistant") {
1510
2687
  for (const block of message.message?.content ?? []) {
1511
2688
  if (isTextBlock(block) && block.text?.trim()) {
1512
- progress?.onActivity?.(firstLine(block.text));
2689
+ emitActivity(firstLine(block.text));
1513
2690
  } else if (isToolUseBlock(block)) {
1514
- progress?.onActivity?.(describeToolUse(block));
2691
+ emitActivity(describeToolUse(block));
1515
2692
  }
1516
2693
  }
1517
2694
  } else if (message.type === "result") {
@@ -1531,6 +2708,16 @@ async function runPass(opts) {
1531
2708
  }
1532
2709
  return { summary, costUsd, ok, maxedOut };
1533
2710
  }
2711
+ function createToolPermissionCallback(repoPath) {
2712
+ return async (toolName, input, options) => {
2713
+ const decision = evaluateToolUse(toolName, input, { repoPath });
2714
+ return decision.behavior === "allow" ? { behavior: "allow", toolUseID: options.toolUseID } : {
2715
+ behavior: "deny",
2716
+ message: decision.message,
2717
+ toolUseID: options.toolUseID
2718
+ };
2719
+ };
2720
+ }
1534
2721
  function applyModelAuthEnv(config, session) {
1535
2722
  if (config.directAnthropicKey) {
1536
2723
  process.env.ANTHROPIC_API_KEY = config.directAnthropicKey;
@@ -1582,102 +2769,6 @@ function short(s, max = 60) {
1582
2769
  return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
1583
2770
  }
1584
2771
 
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
2772
  // src/core/verify.ts
1682
2773
  async function pollFirstEvent(config, session, opts = {}) {
1683
2774
  if (config.offline) return { received: false };
@@ -1705,6 +2796,10 @@ async function pollFirstEvent(config, session, opts = {}) {
1705
2796
 
1706
2797
  // src/core/postflight.ts
1707
2798
  import { spawn as spawn2 } from "child_process";
2799
+ function verdictToVerified(verdict) {
2800
+ if (verdict.toolMissing || verdict.timedOut) return null;
2801
+ return verdict.ok;
2802
+ }
1708
2803
  var MAX_OUTPUT = 4e3;
1709
2804
  var DEFAULT_TIMEOUT_MS = 18e4;
1710
2805
  function runVerifyCommand(repoPath, command, opts = {}) {
@@ -1712,7 +2807,7 @@ function runVerifyCommand(repoPath, command, opts = {}) {
1712
2807
  return Promise.resolve({ ran: false, ok: true, output: "" });
1713
2808
  }
1714
2809
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1715
- return new Promise((resolve2) => {
2810
+ return new Promise((resolve3) => {
1716
2811
  const child = spawn2(command, { cwd: repoPath, shell: true, env: process.env });
1717
2812
  let out = "";
1718
2813
  const append = (d) => {
@@ -1723,36 +2818,36 @@ function runVerifyCommand(repoPath, command, opts = {}) {
1723
2818
  child.stderr?.on("data", append);
1724
2819
  const timer = setTimeout(() => {
1725
2820
  child.kill("SIGKILL");
1726
- resolve2({ ran: true, ok: false, command, output: tail(out), timedOut: true });
2821
+ resolve3({ ran: true, ok: false, command, output: tail2(out), timedOut: true });
1727
2822
  }, timeoutMs);
1728
2823
  child.on("close", (code) => {
1729
2824
  clearTimeout(timer);
1730
- resolve2({
2825
+ resolve3({
1731
2826
  ran: true,
1732
2827
  ok: code === 0,
1733
2828
  command,
1734
2829
  exitCode: code,
1735
- output: tail(out),
2830
+ output: tail2(out),
1736
2831
  // 127 = "command not found": the verify tool isn't on PATH here.
1737
2832
  toolMissing: code === 127
1738
2833
  });
1739
2834
  });
1740
2835
  child.on("error", () => {
1741
2836
  clearTimeout(timer);
1742
- resolve2({ ran: true, ok: false, command, output: tail(out), toolMissing: true });
2837
+ resolve3({ ran: true, ok: false, command, output: tail2(out), toolMissing: true });
1743
2838
  });
1744
2839
  });
1745
2840
  }
1746
- function tail(s) {
2841
+ function tail2(s) {
1747
2842
  const t = s.trim();
1748
2843
  return t.length > MAX_OUTPUT ? `\u2026${t.slice(-MAX_OUTPUT)}` : t;
1749
2844
  }
1750
2845
 
1751
2846
  // src/core/report.ts
1752
2847
  async function postRunReport(config, session, report) {
1753
- if (config.offline) return;
2848
+ if (config.offline) return { ok: true };
1754
2849
  try {
1755
- await fetch(`${config.apiBaseUrl}/wizard/report`, {
2850
+ const res = await fetch(`${config.apiBaseUrl}/wizard/report`, {
1756
2851
  method: "POST",
1757
2852
  headers: {
1758
2853
  "Content-Type": "application/json",
@@ -1760,9 +2855,30 @@ async function postRunReport(config, session, report) {
1760
2855
  },
1761
2856
  body: JSON.stringify(report)
1762
2857
  });
1763
- } catch {
2858
+ if (res.ok) return { ok: true };
2859
+ return {
2860
+ ok: false,
2861
+ status: res.status,
2862
+ detail: await responseDetail(res)
2863
+ };
2864
+ } catch (err) {
2865
+ return { ok: false, detail: detailSlice(errorMessage(err)) };
2866
+ }
2867
+ }
2868
+ async function responseDetail(res) {
2869
+ try {
2870
+ return detailSlice(await res.text());
2871
+ } catch (err) {
2872
+ return detailSlice(errorMessage(err));
1764
2873
  }
1765
2874
  }
2875
+ function errorMessage(err) {
2876
+ return err instanceof Error ? err.message : String(err);
2877
+ }
2878
+ function detailSlice(detail) {
2879
+ const sliced = detail.slice(0, 200).trim();
2880
+ return sliced || void 0;
2881
+ }
1766
2882
 
1767
2883
  // src/ui/banner.ts
1768
2884
  function banner() {
@@ -1781,7 +2897,7 @@ function banner() {
1781
2897
 
1782
2898
  // src/cli.ts
1783
2899
  async function run2(options) {
1784
- const repoPath = resolve(options.path ?? process.cwd());
2900
+ const repoPath = resolve2(options.path ?? process.cwd());
1785
2901
  const config = resolveConfig(options);
1786
2902
  console.log(banner());
1787
2903
  p.intro(theme.signal("Let's wire Whisperr into your app."));
@@ -1859,44 +2975,99 @@ Flutter is live today; ${theme.bright(
1859
2975
  );
1860
2976
  }
1861
2977
  const spin = p.spinner();
2978
+ const useIntegrationSpinner = Boolean(process.stdout.isTTY);
1862
2979
  let phaseLabel = "Integrating";
1863
2980
  let lastLine = "";
2981
+ let lastActivityLine = "";
1864
2982
  const startedAt = Date.now();
1865
- spin.start(theme.bright(phaseLabel));
1866
- const tick = setInterval(() => {
2983
+ const nextIntegrationMessage = createMessageDeduper();
2984
+ const updateIntegrationMessage = () => {
1867
2985
  const secs = Math.round((Date.now() - startedAt) / 1e3);
1868
- spin.message(
1869
- theme.bright(phaseLabel) + theme.muted(` \xB7 ${secs}s`) + (lastLine ? theme.muted(` \u2014 ${lastLine}`) : "")
1870
- );
1871
- }, 1e3);
2986
+ const message = theme.bright(phaseLabel) + theme.muted(` \xB7 ${secs}s`) + (lastLine ? theme.muted(` \u2014 ${lastLine}`) : "");
2987
+ const next = nextIntegrationMessage(message);
2988
+ if (next) spin.message(next);
2989
+ };
2990
+ if (useIntegrationSpinner) {
2991
+ spin.start(theme.bright(phaseLabel));
2992
+ } else {
2993
+ p.log.step(theme.bright(phaseLabel));
2994
+ }
2995
+ const tick = useIntegrationSpinner ? setInterval(updateIntegrationMessage, 1e3) : void 0;
1872
2996
  const outcome = await runIntegrationAgent({
1873
2997
  repoPath,
1874
2998
  config,
1875
2999
  session,
1876
3000
  playbook: chosen.playbook,
1877
3001
  manifest,
3002
+ ...process.stdout.isTTY && process.stdin.isTTY ? {
3003
+ async onPlanReady(plan) {
3004
+ if (useIntegrationSpinner) {
3005
+ spin.stop(theme.success("Placement plan ready"));
3006
+ }
3007
+ p.note(renderPlacementPlan(plan), "Placement plan");
3008
+ const placeEntries = plan.filter((entry) => entry.decision === "place");
3009
+ let selectedEvents = placeEntries.map((entry) => entry.event);
3010
+ if (placeEntries.length) {
3011
+ const selection = await p.multiselect({
3012
+ message: "Wire these events?",
3013
+ options: placeEntries.map((entry) => ({
3014
+ value: entry.event,
3015
+ label: entry.event,
3016
+ hint: `${entry.file ?? "unknown file"}@${entry.anchor ?? "unknown anchor"}`
3017
+ })),
3018
+ initialValues: selectedEvents,
3019
+ required: false
3020
+ });
3021
+ selectedEvents = p.isCancel(selection) ? [] : selection;
3022
+ }
3023
+ if (useIntegrationSpinner) {
3024
+ spin.start(theme.bright("Continuing integration"));
3025
+ }
3026
+ return filterEventPlan(plan, selectedEvents);
3027
+ }
3028
+ } : {},
1878
3029
  progress: {
1879
3030
  onPhase(label) {
1880
3031
  phaseLabel = label;
1881
3032
  lastLine = "";
3033
+ lastActivityLine = "";
3034
+ if (useIntegrationSpinner) {
3035
+ updateIntegrationMessage();
3036
+ } else {
3037
+ p.log.step(theme.bright(label));
3038
+ }
1882
3039
  },
1883
3040
  onActivity(line) {
3041
+ if (line === lastActivityLine) return;
3042
+ lastActivityLine = line;
1884
3043
  lastLine = line;
3044
+ if (useIntegrationSpinner) updateIntegrationMessage();
1885
3045
  }
1886
3046
  }
1887
3047
  }).catch((err) => {
1888
3048
  p.log.error(err.message);
1889
3049
  return null;
1890
3050
  });
1891
- clearInterval(tick);
3051
+ if (tick) clearInterval(tick);
1892
3052
  if (!outcome) {
1893
- spin.stop(theme.alert("Integration stopped"));
3053
+ if (useIntegrationSpinner) {
3054
+ spin.stop(theme.alert("Integration stopped"));
3055
+ } else {
3056
+ p.log.error(theme.alert("Integration stopped"));
3057
+ }
1894
3058
  await maybeRevert(repoPath, checkpoint, "The run stopped before finishing.");
1895
3059
  return 1;
1896
3060
  }
1897
3061
  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
- spin.stop(stopLabel);
1899
- const files = await changedFiles(repoPath, checkpoint);
3062
+ if (useIntegrationSpinner) {
3063
+ spin.stop(stopLabel);
3064
+ } else if (!outcome.coreOk) {
3065
+ p.log.warn(stopLabel);
3066
+ } else {
3067
+ p.log.success(stopLabel);
3068
+ }
3069
+ const opportunities = await collectOpportunities(repoPath, manifest, checkpoint);
3070
+ let files = await changedFiles(repoPath, checkpoint);
1900
3071
  if (files.length) {
1901
3072
  p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
1902
3073
  }
@@ -1919,7 +3090,8 @@ Flutter is live today; ${theme.bright(
1919
3090
  const cmd = chosen.playbook.verifyCommand;
1920
3091
  const vspin = p.spinner();
1921
3092
  vspin.start(`Verifying the integration \u2014 ${theme.muted(cmd)}`);
1922
- const verdict = await runVerifyCommand(repoPath, cmd);
3093
+ let verdict = await runVerifyCommand(repoPath, cmd);
3094
+ verified = verdictToVerified(verdict);
1923
3095
  if (verdict.toolMissing) {
1924
3096
  vspin.stop(
1925
3097
  theme.warn("Couldn't verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
@@ -1929,36 +3101,142 @@ Flutter is live today; ${theme.bright(
1929
3101
  theme.warn(`Verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
1930
3102
  );
1931
3103
  } else if (verdict.ok) {
1932
- verified = true;
1933
3104
  vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
1934
3105
  } else {
1935
- verified = false;
1936
- vspin.stop(theme.alert(`Verification failed \u2014 ${cmd}`));
3106
+ const remainingBudgetUsd = config.budgetUsd - outcome.costUsd;
3107
+ if (remainingBudgetUsd >= 0.5) {
3108
+ vspin.stop(
3109
+ theme.warn(`Verification failed \u2014 ${cmd}`) + theme.muted(" \u2014 attempting one repair pass")
3110
+ );
3111
+ const repairSpin = p.spinner();
3112
+ repairSpin.start(`Repairing verifier failures \u2014 ${theme.muted(cmd)}`);
3113
+ try {
3114
+ const repair = await runRepairPass({
3115
+ repoPath,
3116
+ config,
3117
+ session,
3118
+ playbook: chosen.playbook,
3119
+ verifyCommand: cmd,
3120
+ verifyOutput: verdict.output.slice(-4e3),
3121
+ budgetUsd: remainingBudgetUsd
3122
+ });
3123
+ outcome.costUsd += repair.costUsd;
3124
+ if (repair.summary.trim()) {
3125
+ outcome.summary = [outcome.summary, `Repair:
3126
+ ${repair.summary}`].filter(Boolean).join("\n\n");
3127
+ p.log.message(repair.summary.trim());
3128
+ }
3129
+ repairSpin.stop(theme.success("Repair pass finished"));
3130
+ } catch (err) {
3131
+ repairSpin.stop(
3132
+ theme.warn("Repair pass failed") + theme.muted(` \u2014 ${err.message}`)
3133
+ );
3134
+ }
3135
+ files = await changedFiles(repoPath, checkpoint);
3136
+ const reverifySpin = p.spinner();
3137
+ reverifySpin.start(`Re-verifying the integration \u2014 ${theme.muted(cmd)}`);
3138
+ verdict = await runVerifyCommand(repoPath, cmd);
3139
+ verified = verdictToVerified(verdict);
3140
+ if (verdict.toolMissing) {
3141
+ reverifySpin.stop(
3142
+ theme.warn("Couldn't verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
3143
+ );
3144
+ } else if (verdict.timedOut) {
3145
+ reverifySpin.stop(
3146
+ theme.warn(`Verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
3147
+ );
3148
+ } else if (verdict.ok) {
3149
+ reverifySpin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
3150
+ } else {
3151
+ reverifySpin.stop(theme.alert(`Verification still failed \u2014 ${cmd}`));
3152
+ }
3153
+ } else {
3154
+ vspin.stop(
3155
+ theme.alert(`Verification failed \u2014 ${cmd}`) + theme.muted(" \u2014 skipped repair because less than $0.50 budget remains")
3156
+ );
3157
+ }
3158
+ if (!verdict.ok && !verdict.toolMissing && !verdict.timedOut) {
3159
+ if (verdict.output) {
3160
+ p.note(verdict.output, theme.warn("Verifier output"));
3161
+ }
3162
+ const reverted = await maybeRevert(
3163
+ repoPath,
3164
+ checkpoint,
3165
+ "The integration didn't pass its build/lint check, so the edits may be broken."
3166
+ );
3167
+ if (reverted) {
3168
+ p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
3169
+ return 1;
3170
+ }
3171
+ }
3172
+ }
3173
+ }
3174
+ const { acceptedEvents, instrumentationSnapshot } = await offerOpportunities({
3175
+ repoPath,
3176
+ config,
3177
+ session,
3178
+ playbook: chosen.playbook,
3179
+ manifest,
3180
+ opportunities,
3181
+ fingerprint,
3182
+ checkpoint,
3183
+ remainingBudgetUsd: config.budgetUsd - outcome.costUsd,
3184
+ repoMap: outcome.repoMap
3185
+ });
3186
+ if (instrumentationSnapshot && chosen.playbook.verifyCommand) {
3187
+ const cmd = chosen.playbook.verifyCommand;
3188
+ const preInstrumentationVerified = verified;
3189
+ const vspin = p.spinner();
3190
+ vspin.start(`Re-verifying after instrumenting the new events \u2014 ${theme.muted(cmd)}`);
3191
+ const verdict = await runVerifyCommand(repoPath, cmd);
3192
+ verified = verdictToVerified(verdict);
3193
+ if (verdict.toolMissing) {
3194
+ vspin.stop(
3195
+ theme.warn("Couldn't re-verify automatically") + theme.muted(` \u2014 \`${cmd}\` isn't available here. Run it yourself before committing.`)
3196
+ );
3197
+ } else if (verdict.timedOut) {
3198
+ vspin.stop(
3199
+ theme.warn(`Re-verification timed out \u2014 \`${cmd}\``) + theme.muted(" \u2014 run it yourself before committing.")
3200
+ );
3201
+ } else if (verdict.ok) {
3202
+ vspin.stop(theme.success("Verified \u2713") + theme.muted(` (${cmd})`));
3203
+ } else {
3204
+ vspin.stop(theme.alert(`Verification failed after instrumenting the new events \u2014 ${cmd}`));
1937
3205
  if (verdict.output) {
1938
3206
  p.note(verdict.output, theme.warn("Verifier output"));
1939
3207
  }
1940
- const reverted = await maybeRevert(
1941
- repoPath,
1942
- checkpoint,
1943
- "The integration didn't pass its build/lint check, so the edits may be broken."
1944
- );
1945
- if (reverted) {
1946
- p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
1947
- return 1;
3208
+ const doRevert = await p.confirm({
3209
+ 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.)",
3210
+ initialValue: true
3211
+ });
3212
+ if (!p.isCancel(doRevert) && doRevert) {
3213
+ if (await restoreToSnapshot(repoPath, checkpoint, instrumentationSnapshot)) {
3214
+ verified = preInstrumentationVerified;
3215
+ p.log.success(theme.success("Reverted the instrumentation edits."));
3216
+ } else {
3217
+ p.log.warn(
3218
+ theme.warn("Couldn't revert automatically \u2014 undo manually: ") + (revertHint(checkpoint) ?? "git reset --hard")
3219
+ );
3220
+ }
3221
+ } else {
3222
+ p.log.info(
3223
+ theme.muted("Left in your working tree \u2014 fix the verifier errors before committing.")
3224
+ );
1948
3225
  }
1949
3226
  }
1950
3227
  }
1951
- const wiredMap = await scanWiredEvents(
1952
- 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)
3228
+ const filesForScan = acceptedEvents.length ? await changedFiles(repoPath, checkpoint) : files;
3229
+ const eventTypesToScan = [
3230
+ ...manifest.events.map((e) => e.eventType),
3231
+ ...acceptedEvents.map((e) => e.code)
3232
+ ];
3233
+ const wiredMap = await scanWiredEvents(repoPath, filesForScan, eventTypesToScan);
3234
+ const reportEvents = eventTypesToScan.map((eventType) => ({
3235
+ event_type: eventType,
3236
+ status: wiredMap.has(eventType) ? "wired" : "skipped",
3237
+ file: wiredMap.get(eventType)
1960
3238
  }));
1961
- await postRunReport(config, session, {
3239
+ const reportResult = await postRunReport(config, session, {
1962
3240
  target: chosen.playbook.target.id,
1963
3241
  repo_fingerprint: fingerprint,
1964
3242
  identify_wired: outcome.coreOk,
@@ -1968,9 +3246,23 @@ Flutter is live today; ${theme.bright(
1968
3246
  summary: outcome.summary.slice(0, 4e3),
1969
3247
  events: reportEvents
1970
3248
  });
3249
+ if (!reportResult.ok) {
3250
+ p.log.warn(
3251
+ theme.warn("Couldn't record this run's coverage on the server") + theme.muted(
3252
+ ` (${formatReportFailure(reportResult)}) \u2014 future runs may re-propose events already wired here.`
3253
+ )
3254
+ );
3255
+ }
3256
+ const eventLines = renderEventOutcomeLines(outcome.eventOutcomes, wiredMap);
3257
+ if (eventLines) {
3258
+ p.note(eventLines, "Events");
3259
+ }
3260
+ const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
3261
+ const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
3262
+ const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
1971
3263
  p.log.info(
1972
3264
  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`
3265
+ `${wiredMap.size}/${eventDenominator} ${eventStatsLabel} \xB7 ${files.length} file${files.length === 1 ? "" : "s"} changed \xB7 ` + (verified === true ? "verified \xB7 " : verified === false ? "unverified \xB7 " : "") + formatDuration(outcome.durationMs) + (timingDetails ? ` (${timingDetails})` : "")
1974
3266
  )
1975
3267
  );
1976
3268
  if (!config.offline) {
@@ -2005,6 +3297,160 @@ Flutter is live today; ${theme.bright(
2005
3297
  p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
2006
3298
  return 0;
2007
3299
  }
3300
+ var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
3301
+ async function offerOpportunities(opts) {
3302
+ const { opportunities, manifest, config, session } = opts;
3303
+ const total = opportunities.events.length + opportunities.interventions.length;
3304
+ if (total === 0) return NO_ADDITIONS;
3305
+ const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
3306
+ p.note(
3307
+ [
3308
+ ...opportunities.events.map((e) => describe("event", e.code, e.rationale ?? e.description)),
3309
+ ...opportunities.interventions.map(
3310
+ (i) => describe("intervention", i.code, i.rationale ?? i.description)
3311
+ )
3312
+ ].join("\n"),
3313
+ theme.signal("Opportunities found beyond your onboarding plan")
3314
+ );
3315
+ if (config.offline) {
3316
+ p.log.info(theme.muted("Offline mode \u2014 proposals shown only, nothing submitted."));
3317
+ return NO_ADDITIONS;
3318
+ }
3319
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
3320
+ p.log.info(
3321
+ theme.muted("Non-interactive run \u2014 proposals were not submitted. Re-run interactively to add them.")
3322
+ );
3323
+ return NO_ADDITIONS;
3324
+ }
3325
+ const selection = await p.multiselect({
3326
+ message: "Add these to your Whisperr universe? (new interventions start paused)",
3327
+ options: [
3328
+ ...opportunities.events.map((e) => ({
3329
+ value: `e:${e.code}`,
3330
+ label: `event \xB7 ${e.code}`,
3331
+ hint: e.description ?? e.rationale
3332
+ })),
3333
+ ...opportunities.interventions.map((i) => ({
3334
+ value: `i:${i.code}`,
3335
+ label: `intervention \xB7 ${i.code}`,
3336
+ hint: i.description ?? i.rationale
3337
+ }))
3338
+ ],
3339
+ // Opt-in per item: nothing is pre-selected, so adding to the universe is an
3340
+ // explicit choice rather than an opt-out the user has to notice and undo.
3341
+ initialValues: [],
3342
+ required: false
3343
+ });
3344
+ if (p.isCancel(selection) || selection.length === 0) {
3345
+ p.log.info(theme.muted("Skipped \u2014 your universe is unchanged."));
3346
+ return NO_ADDITIONS;
3347
+ }
3348
+ const chosenSet = new Set(selection);
3349
+ const knownInterventionCodes = new Set(
3350
+ manifest.events.flatMap((e) => e.interventions ?? []).map((i) => i.code)
3351
+ );
3352
+ const knownEventCodes = new Set(manifest.events.map((e) => e.eventType));
3353
+ const pickedEvents = opportunities.events.filter((e) => chosenSet.has(`e:${e.code}`));
3354
+ const pickedInterventions = opportunities.interventions.filter(
3355
+ (i) => chosenSet.has(`i:${i.code}`)
3356
+ );
3357
+ const pickedInterventionCodes = new Set(pickedInterventions.map((i) => i.code));
3358
+ const pickedEventCodes = new Set(pickedEvents.map((e) => e.code));
3359
+ const submitted = {
3360
+ events: pickedEvents.map((e) => ({
3361
+ ...e,
3362
+ links: e.links?.filter(
3363
+ (l) => knownInterventionCodes.has(l.interventionCode) || pickedInterventionCodes.has(l.interventionCode)
3364
+ )
3365
+ })),
3366
+ interventions: pickedInterventions.map((i) => ({
3367
+ ...i,
3368
+ links: i.links?.filter(
3369
+ (l) => knownEventCodes.has(l.eventCode) || pickedEventCodes.has(l.eventCode)
3370
+ )
3371
+ }))
3372
+ };
3373
+ let result;
3374
+ try {
3375
+ result = await withSpinner(
3376
+ "Adding to your universe",
3377
+ () => submitAdditions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
3378
+ );
3379
+ } catch (err) {
3380
+ p.log.warn(
3381
+ theme.warn("Couldn't add the proposals") + theme.muted(` \u2014 ${err.message}. Your universe is unchanged.`)
3382
+ );
3383
+ return NO_ADDITIONS;
3384
+ }
3385
+ const lines = result.outcomes.map((o) => {
3386
+ const mark = o.status === "applied" ? theme.success("\u2713") : o.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3387
+ 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})`) : "";
3388
+ return `${mark} ${o.kind} ${theme.bright(o.code)}${detail}`;
3389
+ });
3390
+ lines.push(
3391
+ theme.muted(
3392
+ `${result.applied} added \xB7 ${result.duplicates} already present \xB7 ${result.invalid} rejected`
3393
+ )
3394
+ );
3395
+ if (submitted.interventions.length) {
3396
+ lines.push(theme.muted("New interventions start paused \u2014 activate them in your dashboard."));
3397
+ }
3398
+ if (result.policyRegen?.status === "pending") {
3399
+ lines.push(
3400
+ theme.muted("Runtime policy update queued \u2014 the additions go live once it completes.")
3401
+ );
3402
+ }
3403
+ if (result.policyRegen?.status === "draft") {
3404
+ lines.push(
3405
+ theme.muted(
3406
+ "A review policy draft was queued \u2014 activate it in your dashboard once it finishes generating to take the additions live (your current live policy was left untouched)."
3407
+ )
3408
+ );
3409
+ }
3410
+ p.note(lines.join("\n"), theme.signal("Universe updated"));
3411
+ if (result.policyRegen?.status === "failed") {
3412
+ p.log.warn(
3413
+ theme.warn("Live runtime NOT updated") + theme.muted(
3414
+ " \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."
3415
+ )
3416
+ );
3417
+ }
3418
+ const appliedEventCodes = new Set(
3419
+ result.outcomes.filter((o) => o.kind === "event" && o.status === "applied").map((o) => o.code)
3420
+ );
3421
+ const acceptedEvents = pickedEvents.filter((e) => appliedEventCodes.has(e.code));
3422
+ if (!acceptedEvents.length) return NO_ADDITIONS;
3423
+ const prePass = await snapshotChanges(opts.repoPath, opts.checkpoint);
3424
+ const spin = p.spinner();
3425
+ spin.start(theme.bright("Instrumenting the newly added events"));
3426
+ try {
3427
+ const pass = await runAdditionsInstrumentationPass({
3428
+ repoPath: opts.repoPath,
3429
+ config,
3430
+ session,
3431
+ playbook: opts.playbook,
3432
+ acceptedEvents,
3433
+ budgetUsd: opts.remainingBudgetUsd,
3434
+ repoMap: opts.repoMap
3435
+ });
3436
+ if (pass.ran) {
3437
+ spin.stop(theme.success("New events instrumented"));
3438
+ if (pass.summary.trim()) p.log.message(pass.summary.trim());
3439
+ } else {
3440
+ spin.stop(
3441
+ theme.warn("Skipped instrumenting the new events") + theme.muted(
3442
+ " \u2014 the spend limit was reached. They're in your universe; wire them on a future run."
3443
+ )
3444
+ );
3445
+ }
3446
+ } catch (err) {
3447
+ spin.stop(
3448
+ theme.warn("Couldn't instrument the new events") + theme.muted(` \u2014 ${err.message}. They're in your universe; wire them on a future run.`)
3449
+ );
3450
+ }
3451
+ const edited = !snapshotsEqual(prePass, await snapshotChanges(opts.repoPath, opts.checkpoint));
3452
+ return { acceptedEvents, instrumentationSnapshot: edited ? prePass : null };
3453
+ }
2008
3454
  async function maybeRevert(repoPath, checkpoint, reason) {
2009
3455
  if (!checkpoint.isRepo) return false;
2010
3456
  const files = await changedFiles(repoPath, checkpoint);
@@ -2092,20 +3538,99 @@ async function withSpinner(label, fn) {
2092
3538
  throw err;
2093
3539
  }
2094
3540
  }
3541
+ function createMessageDeduper() {
3542
+ let last = "";
3543
+ return (message) => {
3544
+ if (message === last) return null;
3545
+ last = message;
3546
+ return message;
3547
+ };
3548
+ }
3549
+ function formatDuration(ms) {
3550
+ const safeMs = Math.max(0, ms);
3551
+ const totalSeconds = Math.round(safeMs / 1e3);
3552
+ if (safeMs < 6e4) return `${totalSeconds}s`;
3553
+ const minutes = Math.floor(totalSeconds / 60);
3554
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
3555
+ return `${minutes}m${seconds}s`;
3556
+ }
3557
+ function filterEventPlan(plan, selectedEvents) {
3558
+ const selected = new Set(selectedEvents);
3559
+ return plan.map(
3560
+ (entry) => entry.decision === "place" && !selected.has(entry.event) ? { ...entry, decision: "skip", reason: "Deselected in plan review." } : entry
3561
+ );
3562
+ }
3563
+ function renderPlacementPlan(plan) {
3564
+ return plan.map((entry) => {
3565
+ if (entry.decision === "place") {
3566
+ return theme.success("\u2713 ") + entry.event + theme.muted(
3567
+ ` \u2014 ${entry.file ?? "unknown file"} @ ${entry.anchor ?? "unknown anchor"}`
3568
+ );
3569
+ }
3570
+ const reason = entry.reason || "No reason provided.";
3571
+ const line = `${entry.decision === "skip" ? "\u25CB" : "?"} ${entry.event} \u2014 ${reason}`;
3572
+ return entry.decision === "skip" ? theme.muted(line) : theme.warn(line);
3573
+ }).join("\n");
3574
+ }
3575
+ function renderEventOutcomeLines(outcomes, wiredMap) {
3576
+ const maxLines = 40;
3577
+ const visibleCount = outcomes.length > maxLines ? maxLines - 1 : outcomes.length;
3578
+ const lines = outcomes.slice(0, visibleCount).map((outcome) => {
3579
+ if (outcome.outcome === "wired") {
3580
+ const file = wiredMap.get(outcome.event);
3581
+ return theme.success("\u2713 ") + outcome.event + (file ? theme.muted(` \u2014 ${file}`) : "");
3582
+ }
3583
+ return theme.muted(
3584
+ `\u25CB ${outcome.event} \u2014 ${outcome.reason || "No track() call was detected."}`
3585
+ );
3586
+ });
3587
+ const remainder = outcomes.length - visibleCount;
3588
+ if (remainder > 0) lines.push(theme.muted(`\u2026 ${remainder} more events`));
3589
+ return lines.join("\n");
3590
+ }
3591
+ function shortPhaseLabel(phase) {
3592
+ switch (phase) {
3593
+ case "Mapping your codebase":
3594
+ return "map";
3595
+ case "Installing the SDK & wiring identify()":
3596
+ return "core";
3597
+ case "Planning event placements":
3598
+ return "plan";
3599
+ case "Instrumenting planned events":
3600
+ case "Instrumenting your events":
3601
+ return "wire";
3602
+ case "Reconciling missed events":
3603
+ return "reconcile";
3604
+ case "Reviewing & correcting placements":
3605
+ return "review";
3606
+ default:
3607
+ return phase;
3608
+ }
3609
+ }
2095
3610
  function summarizeManifest(m) {
2096
3611
  const events = [...m.events].sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0)).map((e) => theme.muted("\u2022 ") + e.eventType);
2097
3612
  const channels = m.identify.channels?.length ? theme.muted(`channels: ${m.identify.channels.join(", ")}`) : "";
3613
+ const firstLine2 = m.universeSummary ? theme.bright(`${m.universeSummary.total} events in your universe`) + theme.muted(
3614
+ ` \u2014 ${m.universeSummary.wireableHere} wireable here \xB7 ${m.universeSummary.derived} computed by Whisperr from your raw events \xB7 ${m.universeSummary.otherSurface} live on other surfaces`
3615
+ ) : theme.bright("identify()") + theme.muted(" + ") + theme.bright(`${m.events.length} events`);
2098
3616
  return [
2099
- theme.bright("identify()") + theme.muted(" + ") + theme.bright(`${m.events.length} events`),
3617
+ firstLine2,
2100
3618
  ...events,
2101
3619
  channels
2102
3620
  ].filter(Boolean).join("\n");
2103
3621
  }
3622
+ function formatReportFailure(result) {
3623
+ const detail = result.detail?.replace(/\s+/g, " ").trim();
3624
+ if (result.status !== void 0) {
3625
+ return `HTTP ${result.status}${detail ? `: ${detail}` : ""}`;
3626
+ }
3627
+ return detail || "request failed";
3628
+ }
2104
3629
 
2105
3630
  // src/index.ts
2106
3631
  var modulePath = fileURLToPath(import.meta.url);
2107
3632
  function packageVersion() {
2108
- const packagePath = join3(dirname(modulePath), "..", "package.json");
3633
+ const packagePath = join4(dirname2(modulePath), "..", "package.json");
2109
3634
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
2110
3635
  return pkg.version ?? "0.0.0";
2111
3636
  }