@whisperr/wizard 0.5.4 → 0.7.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 +2794 -912
  3. package/package.json +6 -4
package/dist/index.js CHANGED
@@ -1,19 +1,18 @@
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
- import { resolve as resolve2 } from "path";
10
- import * as p from "@clack/prompts";
8
+ import { resolve as resolve3 } from "path";
9
+ import * as p2 from "@clack/prompts";
11
10
  import open2 from "open";
12
11
 
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
 
@@ -938,7 +940,7 @@ var ALL_PLAYBOOKS = [
938
940
  swiftPlaybook
939
941
  ];
940
942
  function playbookByTargetId(id) {
941
- return ALL_PLAYBOOKS.find((p2) => p2.target.id === id);
943
+ return ALL_PLAYBOOKS.find((p3) => p3.target.id === id);
942
944
  }
943
945
 
944
946
  // src/core/detect.ts
@@ -980,7 +982,7 @@ function makeDetectContext(repoPath) {
980
982
  async function detectStack(repoPath) {
981
983
  const ctx = makeDetectContext(repoPath);
982
984
  const results = await Promise.all(
983
- ALL_PLAYBOOKS.map((p2) => p2.detect(ctx).catch(() => null))
985
+ ALL_PLAYBOOKS.map((p3) => p3.detect(ctx).catch(() => null))
984
986
  );
985
987
  return results.filter((d) => d !== null).sort((a, b) => b.confidence - a.confidence);
986
988
  }
@@ -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" },
@@ -1173,425 +1186,1690 @@ function mockManifest(appId, config) {
1173
1186
  };
1174
1187
  }
1175
1188
 
1176
- // src/core/agent.ts
1177
- import {
1178
- query
1179
- } from "@anthropic-ai/claude-agent-sdk";
1189
+ // src/engine/cli.ts
1190
+ import * as p from "@clack/prompts";
1180
1191
 
1181
- // src/core/git.ts
1182
- import { spawn } from "child_process";
1183
- import { createHash } from "crypto";
1184
- import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
1185
- import { basename, dirname, join as join2 } from "path";
1186
- async function takeCheckpoint(repoPath) {
1187
- const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
1188
- if (!isRepo) return { isRepo: false };
1189
- const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1190
- const head = await run(repoPath, ["rev-parse", "HEAD"]);
1191
- return {
1192
- isRepo: true,
1193
- baseRef: head.ok ? head.stdout.trim() : void 0,
1194
- branch: branch || void 0
1195
- };
1192
+ // src/core/report.ts
1193
+ function scrubSummary(text) {
1194
+ return scrubTerminalControls(text).replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]").replace(/sk-[A-Za-z0-9]{16,}/g, "[redacted]").replace(/AKIA[0-9A-Z]{16}/g, "[redacted]").replace(/Bearer\s+[A-Za-z0-9._-]+/g, "[redacted]").replace(
1195
+ /\b(ANTHROPIC_(?:AUTH_TOKEN|API_KEY))\s*=\s*["']?[^"'\s]+["']?/gi,
1196
+ "$1=[redacted]"
1197
+ );
1196
1198
  }
1197
- async function changedFiles(repoPath, checkpoint) {
1198
- if (!checkpoint.isRepo) return [];
1199
- const tracked = await run(repoPath, ["diff", "--name-only"]);
1200
- const untracked = await run(repoPath, [
1201
- "ls-files",
1202
- "--others",
1203
- "--exclude-standard"
1204
- ]);
1205
- const set = /* @__PURE__ */ new Set();
1206
- for (const f of `${tracked.stdout}
1207
- ${untracked.stdout}`.split("\n")) {
1208
- if (f.trim()) set.add(f.trim());
1199
+ async function postRunReport(config, session, report) {
1200
+ if (config.offline) return { ok: true };
1201
+ try {
1202
+ const res = await fetch(`${config.apiBaseUrl}/wizard/report`, {
1203
+ method: "POST",
1204
+ headers: {
1205
+ "Content-Type": "application/json",
1206
+ Authorization: `Bearer ${session.token}`
1207
+ },
1208
+ body: JSON.stringify(report)
1209
+ });
1210
+ if (res.ok) return { ok: true };
1211
+ return {
1212
+ ok: false,
1213
+ status: res.status,
1214
+ detail: await responseDetail(res)
1215
+ };
1216
+ } catch (err) {
1217
+ return { ok: false, detail: detailSlice(errorMessage(err)) };
1209
1218
  }
1210
- return [...set];
1211
1219
  }
1212
- function revertHint(checkpoint) {
1213
- if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
1214
- return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
1220
+ async function responseDetail(res) {
1221
+ try {
1222
+ return detailSlice(await res.text());
1223
+ } catch (err) {
1224
+ return detailSlice(errorMessage(err));
1225
+ }
1215
1226
  }
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() !== "";
1227
+ function errorMessage(err) {
1228
+ return err instanceof Error ? err.message : String(err);
1230
1229
  }
1231
- async function isWorkingTreeClean(repoPath) {
1232
- const status = await run(repoPath, ["status", "--porcelain"]);
1233
- return status.ok && status.stdout.trim() === "";
1230
+ function detailSlice(detail) {
1231
+ const sliced = detail.slice(0, 200).trim();
1232
+ return sliced || void 0;
1234
1233
  }
1235
- async function revertToCheckpoint(repoPath, checkpoint) {
1236
- if (!checkpoint.isRepo) return false;
1237
- if (checkpoint.baseRef) {
1238
- const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
1239
- const clean2 = await run(repoPath, ["clean", "-fd"]);
1240
- return reset.ok && clean2.ok;
1241
- }
1242
- const restore = await run(repoPath, ["restore", "."]);
1243
- const clean = await run(repoPath, ["clean", "-fd"]);
1244
- return restore.ok && clean.ok;
1234
+ function scrubTerminalControls(value) {
1235
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
1245
1236
  }
1246
- async function snapshotChanges(repoPath, checkpoint) {
1247
- const snapshot = /* @__PURE__ */ new Map();
1248
- for (const file of await changedFiles(repoPath, checkpoint)) {
1237
+
1238
+ // src/core/verify.ts
1239
+ async function pollFirstEvent(config, session, opts = {}) {
1240
+ if (config.offline) return { received: false };
1241
+ const timeoutMs = opts.timeoutMs ?? 12e4;
1242
+ const intervalMs = opts.intervalMs ?? 3e3;
1243
+ const deadline = Date.now() + timeoutMs;
1244
+ while (Date.now() < deadline) {
1245
+ if (opts.signal?.aborted) return { received: false };
1249
1246
  try {
1250
- snapshot.set(file, await readFile2(join2(repoPath, file)));
1247
+ const res = await fetch(`${config.apiBaseUrl}/wizard/first-event`, {
1248
+ headers: { authorization: `Bearer ${session.token}` }
1249
+ });
1250
+ if (res.ok) {
1251
+ const body = await res.json();
1252
+ if (body.received) {
1253
+ return { received: true, eventType: body.event_type };
1254
+ }
1255
+ }
1251
1256
  } 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
1257
  }
1258
+ await new Promise((r) => setTimeout(r, intervalMs));
1267
1259
  }
1268
- return true;
1260
+ return { received: false };
1269
1261
  }
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);
1262
+
1263
+ // src/engine/output.ts
1264
+ function formatProgressLine(event) {
1265
+ const parts = [event.phase];
1266
+ if (event.cluster) {
1267
+ parts.push(`cluster ${event.cluster.index}/${event.cluster.total}`);
1268
+ }
1269
+ if (event.file) {
1270
+ parts.push(event.file);
1271
+ }
1272
+ parts.push(event.action);
1273
+ return parts.join(" \xB7 ");
1274
+ }
1275
+ function createProgressSink(options) {
1276
+ const interval = options.heartbeatIntervalMs ?? 15e3;
1277
+ const now = options.now ?? (() => Date.now());
1278
+ let lastLineAt = 0;
1279
+ let lastLine = "";
1280
+ const writeLine = (line) => {
1281
+ lastLineAt = now();
1282
+ lastLine = line;
1283
+ options.write(line);
1284
+ };
1285
+ return {
1286
+ emit(event) {
1287
+ if (options.json) {
1288
+ writeLine(JSON.stringify({ kind: "progress", ...event }));
1289
+ return;
1290
+ }
1291
+ const line = formatProgressLine(event);
1292
+ if (!options.isTTY && line === lastLine) {
1293
+ return;
1287
1294
  }
1295
+ writeLine(line);
1296
+ },
1297
+ heartbeat(phase, action) {
1298
+ if (now() - lastLineAt < interval) {
1299
+ return;
1300
+ }
1301
+ if (options.json) {
1302
+ writeLine(JSON.stringify({ kind: "heartbeat", phase, action }));
1303
+ return;
1304
+ }
1305
+ writeLine(`${phase} \xB7 still working \xB7 ${action}`);
1288
1306
  }
1289
- return true;
1290
- } catch {
1291
- return false;
1292
- }
1293
- }
1294
- async function repoFingerprint(repoPath) {
1295
- const remote = await run(repoPath, ["remote", "get-url", "origin"]);
1296
- const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
1297
- return createHash("sha256").update(seed).digest("hex").slice(0, 16);
1298
- }
1299
- function normalizeRemote(url) {
1300
- return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1307
+ };
1301
1308
  }
1302
- async function scanWiredEvents(repoPath, files, eventTypes) {
1303
- const wired = /* @__PURE__ */ new Map();
1304
- const patterns = eventTypes.map((e) => ({
1305
- eventType: e,
1306
- trackRe: new RegExp(
1307
- `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
1308
- ),
1309
- literalRe: quotedLiteralRegExp(e)
1310
- }));
1311
- const contents = [];
1312
- for (const file of files) {
1313
- try {
1314
- contents.push({ file, content: await readFile2(join2(repoPath, file), "utf8") });
1315
- } catch {
1309
+
1310
+ // src/engine/orchestrator.ts
1311
+ import { basename, join as join6 } from "path";
1312
+
1313
+ // src/engine/clusters.ts
1314
+ import { createHash } from "crypto";
1315
+ import { dirname } from "path";
1316
+ var MIN_CLUSTER_EVENTS = 3;
1317
+ var MAX_CLUSTER_EVENTS = 8;
1318
+ function clusterIdFor(files) {
1319
+ const digest = createHash("sha256").update([...files].sort().join("\n")).digest("hex");
1320
+ return `cluster_${digest.slice(0, 12)}`;
1321
+ }
1322
+ function deriveClusters(placements) {
1323
+ const byFile = /* @__PURE__ */ new Map();
1324
+ for (const placement of placements) {
1325
+ const existing = byFile.get(placement.file) ?? [];
1326
+ existing.push(placement);
1327
+ byFile.set(placement.file, existing);
1328
+ }
1329
+ const byDirectory = /* @__PURE__ */ new Map();
1330
+ for (const [file, events] of byFile) {
1331
+ const directory = dirname(file);
1332
+ const bucket = byDirectory.get(directory) ?? { files: /* @__PURE__ */ new Set(), events: [] };
1333
+ bucket.files.add(file);
1334
+ bucket.events.push(...events);
1335
+ byDirectory.set(directory, bucket);
1336
+ }
1337
+ const clusters = [];
1338
+ const pending = [];
1339
+ for (const directory of [...byDirectory.keys()].sort()) {
1340
+ const bucket = byDirectory.get(directory);
1341
+ if (bucket.events.length >= MIN_CLUSTER_EVENTS) {
1342
+ pending.push(bucket);
1316
1343
  continue;
1317
1344
  }
1318
- }
1319
- for (const { file, content } of contents) {
1320
- for (const { eventType, trackRe } of patterns) {
1321
- if (!wired.has(eventType) && trackRe.test(content)) {
1322
- wired.set(eventType, file);
1323
- }
1345
+ const last = pending[pending.length - 1];
1346
+ if (last && last.events.length + bucket.events.length <= MAX_CLUSTER_EVENTS) {
1347
+ for (const file of bucket.files) last.files.add(file);
1348
+ last.events.push(...bucket.events);
1349
+ } else {
1350
+ pending.push(bucket);
1324
1351
  }
1325
1352
  }
1326
- for (const { file, content } of contents) {
1327
- for (const { eventType, literalRe } of patterns) {
1328
- if (!wired.has(eventType) && literalRe.test(content)) {
1329
- wired.set(eventType, file);
1330
- }
1353
+ for (const bucket of pending) {
1354
+ const events = [...bucket.events].sort((a, b) => a.eventCode.localeCompare(b.eventCode));
1355
+ for (let start = 0; start < events.length; start += MAX_CLUSTER_EVENTS) {
1356
+ const slice = events.slice(start, start + MAX_CLUSTER_EVENTS);
1357
+ const files = [...new Set(slice.map((event) => event.file))].sort();
1358
+ clusters.push({
1359
+ id: clusterIdFor(files.length > 0 ? files : [`slice_${start}`]),
1360
+ files,
1361
+ events: slice,
1362
+ status: "pending",
1363
+ attempts: 0
1364
+ });
1331
1365
  }
1332
1366
  }
1333
- return wired;
1367
+ return clusters;
1334
1368
  }
1335
- function quotedLiteralRegExp(eventType) {
1336
- const leftBoundary = /^\w/.test(eventType) ? "\\b" : "";
1337
- const rightBoundary = /\w$/.test(eventType) ? "\\b" : "";
1338
- return new RegExp(`(['"\`])${leftBoundary}${escapeRegExp(eventType)}${rightBoundary}\\1`);
1369
+ var TERMINAL_STATUSES = ["reviewed", "failed_resumable", "blocked"];
1370
+ function nextPendingCluster(clusters) {
1371
+ return clusters.find((cluster) => cluster.status === "pending" || cluster.status === "editing");
1339
1372
  }
1340
- function escapeRegExp(s) {
1341
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1373
+ function clustersComplete(clusters) {
1374
+ return clusters.every((cluster) => TERMINAL_STATUSES.includes(cluster.status));
1342
1375
  }
1343
- function run(cwd, args) {
1344
- return new Promise((resolve3) => {
1345
- const child = spawn("git", args, { cwd });
1346
- let stdout = "";
1347
- let stderr = "";
1348
- child.stdout.on("data", (d) => stdout += d.toString());
1349
- child.stderr.on("data", (d) => stderr += d.toString());
1350
- child.on("close", (code) => resolve3({ ok: code === 0, stdout, stderr }));
1351
- child.on("error", () => resolve3({ ok: false, stdout, stderr }));
1376
+ function transitionCluster(clusters, clusterId, status, note3) {
1377
+ return clusters.map((cluster) => {
1378
+ if (cluster.id !== clusterId) {
1379
+ return cluster;
1380
+ }
1381
+ const attempts = status === "editing" ? cluster.attempts + 1 : cluster.attempts;
1382
+ return { ...cluster, status, attempts, note: note3 ?? cluster.note };
1352
1383
  });
1353
1384
  }
1354
1385
 
1355
- // src/core/opportunities.ts
1356
- import { readFile as readFile3, rm as rm2 } from "fs/promises";
1386
+ // src/engine/integrate.ts
1387
+ import { writeFile } from "fs/promises";
1357
1388
  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, "");
1389
+
1390
+ // src/engine/bindings.ts
1391
+ function bindingsTargetFor(target9) {
1392
+ switch (target9) {
1393
+ case "swift":
1394
+ return "swift";
1395
+ case "flutter":
1396
+ return "dart";
1397
+ default:
1398
+ return "typescript";
1399
+ }
1400
+ }
1401
+ function camelCase(code) {
1402
+ return code.split("_").map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
1403
+ }
1404
+ function generateBindings(target9, events) {
1405
+ switch (bindingsTargetFor(target9)) {
1406
+ case "swift":
1407
+ return generateSwift(events);
1408
+ case "dart":
1409
+ return generateDart(events);
1410
+ default:
1411
+ return generateTypeScript(events);
1412
+ }
1413
+ }
1414
+ function generateTypeScript(events) {
1415
+ const lines = [
1416
+ "// Generated by @whisperr/wizard \u2014 do not edit by hand.",
1417
+ "// Call bindWhisperr(client) once right after the Whisperr SDK is initialized;",
1418
+ "// events tracked before binding are queued, never dropped.",
1419
+ "",
1420
+ "type WhisperrPayloadValue = string | number | boolean;",
1421
+ "type WhisperrClientLike = {",
1422
+ " track: (event: string, payload?: Record<string, WhisperrPayloadValue>) => unknown;",
1423
+ "};",
1424
+ "",
1425
+ "let boundClient: WhisperrClientLike | null = null;",
1426
+ "const queued: Array<[string, Record<string, WhisperrPayloadValue> | undefined]> = [];",
1427
+ "",
1428
+ "export function bindWhisperr(client: WhisperrClientLike): void {",
1429
+ " boundClient = client;",
1430
+ " while (queued.length > 0) {",
1431
+ " const [event, payload] = queued.shift()!;",
1432
+ " try { client.track(event, payload); } catch { /* analytics must never break the app */ }",
1433
+ " }",
1434
+ "}",
1435
+ "",
1436
+ "function emit(event: string, payload?: Record<string, WhisperrPayloadValue>): void {",
1437
+ " if (boundClient) {",
1438
+ " try { boundClient.track(event, payload); } catch { /* analytics must never break the app */ }",
1439
+ " } else {",
1440
+ " queued.push([event, payload]);",
1441
+ " }",
1442
+ "}",
1443
+ "",
1444
+ "export const WhisperrEvents = {"
1445
+ ];
1446
+ for (const event of events) {
1447
+ const fields = Object.keys(event.payloadSchema);
1448
+ for (const field of fields) {
1449
+ lines.push(` /** ${field}: ${event.payloadSchema[field]} */`);
1450
+ }
1451
+ if (fields.length === 0) {
1452
+ lines.push(` ${camelCase(event.code)}(): void {`, ` emit("${event.code}");`, " },");
1453
+ } else {
1454
+ const params = fields.map((field) => `${camelCase(field)}: WhisperrPayloadValue`).join("; ");
1455
+ const entries = fields.map((field) => `"${field}": ${camelCase(field)}`).join(", ");
1456
+ lines.push(
1457
+ ` ${camelCase(event.code)}(payload: { ${params} }): void {`,
1458
+ ` const { ${fields.map((field) => camelCase(field)).join(", ")} } = payload;`,
1459
+ ` emit("${event.code}", { ${entries} });`,
1460
+ " },"
1461
+ );
1462
+ }
1463
+ }
1464
+ lines.push("};", "");
1465
+ return {
1466
+ relativePath: "whisperr-events.ts",
1467
+ content: lines.join("\n"),
1468
+ bindInstruction: "Import { bindWhisperr } from the generated whisperr-events module and call bindWhisperr(client) once, immediately after the Whisperr SDK client is created at app startup."
1469
+ };
1470
+ }
1471
+ function generateSwift(events) {
1472
+ const lines = [
1473
+ "// Generated by @whisperr/wizard \u2014 do not edit by hand.",
1474
+ "// Call WhisperrEvents.bind(track:) once right after the Whisperr SDK is ready;",
1475
+ "// events tracked before binding are queued, never dropped.",
1476
+ "",
1477
+ "import Foundation",
1478
+ "",
1479
+ "enum WhisperrEvents {",
1480
+ " typealias TrackFunction = (String, [String: String]) -> Void",
1481
+ " private static var boundTrack: TrackFunction?",
1482
+ " private static var queued: [(String, [String: String])] = []",
1483
+ " private static let lock = NSLock()",
1484
+ "",
1485
+ " static func bind(track: @escaping TrackFunction) {",
1486
+ " lock.lock()",
1487
+ " boundTrack = track",
1488
+ " let pending = queued",
1489
+ " queued.removeAll()",
1490
+ " lock.unlock()",
1491
+ " for (event, payload) in pending { track(event, payload) }",
1492
+ " }",
1493
+ "",
1494
+ " private static func emit(_ event: String, _ payload: [String: String] = [:]) {",
1495
+ " lock.lock()",
1496
+ " if let track = boundTrack {",
1497
+ " lock.unlock()",
1498
+ " track(event, payload)",
1499
+ " } else {",
1500
+ " queued.append((event, payload))",
1501
+ " lock.unlock()",
1502
+ " }",
1503
+ " }",
1504
+ ""
1505
+ ];
1506
+ for (const event of events) {
1507
+ const fields = Object.keys(event.payloadSchema);
1508
+ for (const field of fields) {
1509
+ lines.push(` /// ${field}: ${event.payloadSchema[field]}`);
1510
+ }
1511
+ if (fields.length === 0) {
1512
+ lines.push(` static func ${camelCase(event.code)}() {`, ` emit("${event.code}")`, " }", "");
1513
+ } else {
1514
+ const params = fields.map((field) => `${camelCase(field)}: String`).join(", ");
1515
+ const entries = fields.map((field) => `"${field}": ${camelCase(field)}`).join(", ");
1516
+ lines.push(
1517
+ ` static func ${camelCase(event.code)}(${params}) {`,
1518
+ ` emit("${event.code}", [${entries}])`,
1519
+ " }",
1520
+ ""
1521
+ );
1522
+ }
1523
+ }
1524
+ lines.push("}", "");
1525
+ return {
1526
+ relativePath: "WhisperrEvents.swift",
1527
+ content: lines.join("\n"),
1528
+ bindInstruction: "Call WhisperrEvents.bind(track:) once, immediately after Whisperr SDK initialization completes, forwarding to the SDK's track call. String payload values only."
1529
+ };
1530
+ }
1531
+ function generateDart(events) {
1532
+ const lines = [
1533
+ "// Generated by @whisperr/wizard \u2014 do not edit by hand.",
1534
+ "// Call WhisperrEvents.bind(track) once right after the Whisperr SDK is ready;",
1535
+ "// events tracked before binding are queued, never dropped.",
1536
+ "",
1537
+ "typedef WhisperrTrack = void Function(String event, Map<String, Object> payload);",
1538
+ "",
1539
+ "class WhisperrEvents {",
1540
+ " static WhisperrTrack? _track;",
1541
+ " static final List<MapEntry<String, Map<String, Object>>> _queued = [];",
1542
+ "",
1543
+ " static void bind(WhisperrTrack track) {",
1544
+ " _track = track;",
1545
+ " for (final entry in _queued) {",
1546
+ " track(entry.key, entry.value);",
1547
+ " }",
1548
+ " _queued.clear();",
1549
+ " }",
1550
+ "",
1551
+ " static void _emit(String event, [Map<String, Object> payload = const {}]) {",
1552
+ " final track = _track;",
1553
+ " if (track != null) {",
1554
+ " track(event, payload);",
1555
+ " } else {",
1556
+ " _queued.add(MapEntry(event, payload));",
1557
+ " }",
1558
+ " }",
1559
+ ""
1560
+ ];
1561
+ for (const event of events) {
1562
+ const fields = Object.keys(event.payloadSchema);
1563
+ for (const field of fields) {
1564
+ lines.push(` /// ${field}: ${event.payloadSchema[field]}`);
1565
+ }
1566
+ if (fields.length === 0) {
1567
+ lines.push(` static void ${camelCase(event.code)}() => _emit('${event.code}');`, "");
1568
+ } else {
1569
+ const params = fields.map((field) => `required Object ${camelCase(field)}`).join(", ");
1570
+ const entries = fields.map((field) => `'${field}': ${camelCase(field)}`).join(", ");
1571
+ lines.push(
1572
+ ` static void ${camelCase(event.code)}({${params}}) =>`,
1573
+ ` _emit('${event.code}', {${entries}});`,
1574
+ ""
1575
+ );
1576
+ }
1577
+ }
1578
+ lines.push("}", "");
1579
+ return {
1580
+ relativePath: "whisperr_events.dart",
1581
+ content: lines.join("\n"),
1582
+ bindInstruction: "Call WhisperrEvents.bind(track) once, immediately after the Whisperr SDK is initialized, forwarding to the SDK's track call."
1583
+ };
1584
+ }
1585
+
1586
+ // src/engine/budgets.ts
1587
+ var OVERFLOW_PATTERNS = [
1588
+ /prompt is too long/i,
1589
+ /context.{0,20}(length|window|limit).{0,20}exceed/i,
1590
+ /exceeds? .{0,20}context/i,
1591
+ /maximum context/i,
1592
+ /input is too long/i
1593
+ ];
1594
+ var TRANSIENT_PATTERNS = [
1595
+ /rate.?limit/i,
1596
+ /overloaded/i,
1597
+ /timeout/i,
1598
+ /timed out/i,
1599
+ /econnreset/i,
1600
+ /socket hang up/i,
1601
+ /5\d\d/,
1602
+ /temporarily unavailable/i
1603
+ ];
1604
+ function classifyFailure(message) {
1605
+ const text = message ?? "";
1606
+ if (OVERFLOW_PATTERNS.some((pattern) => pattern.test(text))) {
1607
+ return "context_overflow";
1608
+ }
1609
+ if (TRANSIENT_PATTERNS.some((pattern) => pattern.test(text))) {
1610
+ return "transient";
1611
+ }
1612
+ return "fatal";
1613
+ }
1614
+ var MAX_CLUSTER_ATTEMPTS = 2;
1615
+ function nextClusterStatusAfterFailure(failure, attempts) {
1616
+ if (failure === "transient" && attempts < MAX_CLUSTER_ATTEMPTS) {
1617
+ return "pending";
1618
+ }
1619
+ if (failure === "fatal") {
1620
+ return "blocked";
1621
+ }
1622
+ return "failed_resumable";
1361
1623
  }
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: [] };
1624
+
1625
+ // src/engine/verify.ts
1626
+ import { spawn } from "child_process";
1627
+ import { readFileSync } from "fs";
1628
+ import { join as join2 } from "path";
1629
+ var JS_TARGETS = /* @__PURE__ */ new Set(["web-js", "nextjs", "node", "react-native"]);
1630
+ function resolveVerifySteps(repoPath, target9) {
1631
+ if (target9 === "flutter") {
1632
+ return [{ kind: "analyze", command: "flutter", args: ["analyze", "--no-pub"] }];
1633
+ }
1634
+ if (JS_TARGETS.has(target9)) {
1635
+ const scripts = packageScripts(repoPath);
1636
+ const steps = [];
1637
+ if (scripts.typecheck) {
1638
+ steps.push({ kind: "typecheck", command: npmCommand(), args: ["run", "typecheck"] });
1639
+ } else if (hasTsconfig(repoPath)) {
1640
+ steps.push({ kind: "typecheck", command: "npx", args: ["--no-install", "tsc", "--noEmit"] });
1641
+ }
1642
+ if (steps.length === 0 && scripts.build) {
1643
+ steps.push({ kind: "build", command: npmCommand(), args: ["run", "build"] });
1644
+ }
1645
+ return steps;
1366
1646
  }
1367
- let rawText;
1647
+ return [];
1648
+ }
1649
+ function packageScripts(repoPath) {
1368
1650
  try {
1369
- rawText = await readFile3(path, "utf8");
1651
+ const parsed = JSON.parse(readFileSync(join2(repoPath, "package.json"), "utf8"));
1652
+ return parsed.scripts ?? {};
1370
1653
  } catch {
1371
- return { events: [], interventions: [] };
1654
+ return {};
1372
1655
  }
1373
- await rm2(path, { force: true }).catch(() => {
1374
- });
1375
- let raw;
1656
+ }
1657
+ function hasTsconfig(repoPath) {
1376
1658
  try {
1377
- raw = JSON.parse(rawText);
1659
+ readFileSync(join2(repoPath, "tsconfig.json"), "utf8");
1660
+ return true;
1378
1661
  } catch {
1379
- return { events: [], interventions: [] };
1662
+ return false;
1380
1663
  }
1381
- return sanitizeOpportunities(raw, manifest);
1382
1664
  }
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);
1665
+ function npmCommand() {
1666
+ return process.platform === "win32" ? "npm.cmd" : "npm";
1667
+ }
1668
+ function runVerifyStep(cwd, step, timeoutMs = 3e5) {
1669
+ return new Promise((resolve4) => {
1670
+ const child = spawn(step.command, step.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1671
+ let output = "";
1672
+ const capture = (chunk) => {
1673
+ if (output.length < 2e4) {
1674
+ output += String(chunk);
1675
+ }
1676
+ };
1677
+ child.stdout.on("data", capture);
1678
+ child.stderr.on("data", capture);
1679
+ const timer = setTimeout(() => {
1680
+ child.kill("SIGKILL");
1681
+ }, timeoutMs);
1682
+ child.on("error", (error) => {
1683
+ clearTimeout(timer);
1684
+ resolve4({ ...step, ok: false, output: String(error) });
1685
+ });
1686
+ child.on("close", (exitCode) => {
1687
+ clearTimeout(timer);
1688
+ resolve4({ ...step, ok: exitCode === 0, output });
1689
+ });
1690
+ });
1691
+ }
1692
+ async function runVerifySteps(cwd, steps) {
1693
+ const results = [];
1694
+ for (const step of steps) {
1695
+ results.push(await runVerifyStep(cwd, step));
1399
1696
  }
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);
1697
+ return results;
1698
+ }
1699
+ function verifiedStatus(results) {
1700
+ if (results.length === 0) {
1701
+ return "applied";
1407
1702
  }
1408
- return out;
1703
+ if (results.some((result) => !result.ok)) {
1704
+ return "applied";
1705
+ }
1706
+ if (results.some((result) => result.kind === "build")) {
1707
+ return "build_verified";
1708
+ }
1709
+ if (results.some((result) => result.kind === "typecheck" || result.kind === "analyze")) {
1710
+ return "type_verified";
1711
+ }
1712
+ return "syntax_verified";
1409
1713
  }
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
- );
1714
+ function runVerificationStatus(results) {
1715
+ if (results.length === 0) {
1716
+ return "unavailable";
1431
1717
  }
1432
- return await res.json();
1718
+ return results.every((result) => result.ok) ? "passed" : "failed";
1433
1719
  }
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}`
1720
+ function verificationCommand(results) {
1721
+ const last = results[results.length - 1];
1722
+ return last ? [last.command, ...last.args].join(" ") : void 0;
1723
+ }
1724
+
1725
+ // src/engine/integrate.ts
1726
+ var REVIEW_CHECKLIST = [
1727
+ "- Events must fire after the confirmed outcome (post-await/success), never on intent or button tap.",
1728
+ "- View-appearance events must be deduplicated against re-renders and navigation returns.",
1729
+ "- Debounced flows must emit after the debounce commits, not per keystroke/tap.",
1730
+ "- Denied/failed permission or authorization results must not be recorded as granted.",
1731
+ "- Never persist a sent/once marker before the track call actually executed.",
1732
+ "- Payloads must use only the generated wrapper fields \u2014 no extra fields, no personal identifiers."
1733
+ ].join("\n");
1734
+ async function writeBindingsModule(worktree, target9, registered) {
1735
+ const bindings = generateBindings(target9, registered);
1736
+ await writeFile(join3(worktree.path, bindings.relativePath), bindings.content);
1737
+ return bindings;
1738
+ }
1739
+ async function runSdkSetupPass(deps, bindings) {
1740
+ deps.sink.emit({ phase: "binding", action: "installing SDK and wiring identify()" });
1741
+ const outcome = await deps.provider.invoke(
1742
+ {
1743
+ role: "edit",
1744
+ systemPrompt: deps.playbookSystemPrompt,
1745
+ userPrompt: [
1746
+ "CORE SETUP for the Whisperr SDK in this repository. Do exactly this, then stop:",
1747
+ "1. Add and install the Whisperr SDK dependency for this stack.",
1748
+ `2. Initialize it once at app startup with API key ${deps.ingestion.apiKey} and base URL ${deps.ingestion.baseUrl}.`,
1749
+ `3. The generated bindings module at ${bindings.relativePath} queues events until bound.`,
1750
+ ` ${bindings.bindInstruction}`,
1751
+ " You may move the generated file to the idiomatic source directory (fix imports), but never edit its generated content.",
1752
+ "4. Wire identify() for the END USER (paying customer) at login success and session restore; reset() on logout.",
1753
+ " Never instrument admin/staff/operator paths.",
1754
+ "Do not instrument product events yet. Do not run builds."
1755
+ ].join("\n"),
1756
+ tools: [],
1757
+ allowRepoWrite: true,
1758
+ cwd: deps.worktree.path
1441
1759
  },
1442
- body: JSON.stringify({
1443
- target: target9,
1444
- repoFingerprint: repoFingerprint2,
1445
- events: opportunities.events,
1446
- interventions: opportunities.interventions
1447
- })
1760
+ deps.models.edit,
1761
+ (action) => deps.sink.emit({ phase: "binding", action })
1762
+ );
1763
+ return outcome.kind === "completed";
1764
+ }
1765
+ function clusterPrompt(cluster, registered, bindings) {
1766
+ const byCode = new Map(registered.map((event) => [event.code, event]));
1767
+ const briefs = cluster.events.map((placement) => {
1768
+ const event = byCode.get(placement.eventCode);
1769
+ const fields = Object.keys(event?.payloadSchema ?? {});
1770
+ return [
1771
+ `- ${placement.eventCode}: call the generated wrapper from ${bindings.relativePath}`,
1772
+ ` anchor: ${placement.file}${placement.symbol ? ` (${placement.symbol})` : ""}`,
1773
+ ` why: ${event?.reasoning ?? ""}`,
1774
+ fields.length > 0 ? ` payload fields: ${fields.join(", ")}` : " payload: none"
1775
+ ].join("\n");
1448
1776
  });
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();
1777
+ return [
1778
+ "Instrument EXACTLY these events by calling their generated wrapper functions at the anchors.",
1779
+ "Use only the generated wrappers \u2014 never call the SDK's raw track() and never invent event names.",
1780
+ "",
1781
+ "Placement rules:",
1782
+ REVIEW_CHECKLIST,
1783
+ "",
1784
+ "Events for this cluster:",
1785
+ ...briefs,
1786
+ "",
1787
+ "Only edit the listed files (plus imports of the bindings module). Then stop."
1788
+ ].join("\n");
1457
1789
  }
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}` }
1790
+ async function reviewCluster(deps, cluster, diff) {
1791
+ const outcome = await deps.provider.invoke(
1792
+ {
1793
+ role: "review",
1794
+ systemPrompt: [
1795
+ "You review an instrumentation diff against a strict checklist. Answer with a verdict line",
1796
+ "`VERDICT: pass` or `VERDICT: fail`, then bullet notes for anything wrong."
1797
+ ].join("\n"),
1798
+ userPrompt: [
1799
+ "Checklist:",
1800
+ REVIEW_CHECKLIST,
1801
+ "",
1802
+ `Events expected: ${cluster.events.map((event) => event.eventCode).join(", ")}`,
1803
+ "",
1804
+ "Diff:",
1805
+ diff.length > 6e4 ? diff.slice(0, 6e4) + "\n\u2026(truncated)" : diff
1806
+ ].join("\n"),
1807
+ tools: [],
1808
+ allowRepoWrite: false,
1809
+ cwd: deps.worktree.path
1810
+ },
1811
+ deps.models.review,
1812
+ (action) => deps.sink.emit({ phase: "reviewing", action })
1813
+ );
1814
+ if (outcome.kind !== "completed") {
1815
+ return { ok: false, notes: `review pass did not complete (${outcome.kind})` };
1816
+ }
1817
+ const ok = /verdict:\s*pass/i.test(outcome.text);
1818
+ return { ok, notes: outcome.text.trim() };
1819
+ }
1820
+ async function runClusterIntegration(deps, initialClusters, clusterDiff) {
1821
+ let clusters = initialClusters;
1822
+ const eventStatuses = /* @__PURE__ */ new Map();
1823
+ const bindings = generateBindings(deps.target, deps.registered);
1824
+ const steps = resolveVerifySteps(deps.worktree.path, deps.target);
1825
+ const total = clusters.length;
1826
+ for (; ; ) {
1827
+ const cluster = nextPendingCluster(clusters);
1828
+ if (!cluster) {
1829
+ break;
1830
+ }
1831
+ const index = clusters.findIndex((candidate) => candidate.id === cluster.id) + 1;
1832
+ clusters = transitionCluster(clusters, cluster.id, "editing");
1833
+ deps.saveClusters(clusters);
1834
+ deps.sink.emit({
1835
+ phase: "integrating",
1836
+ cluster: { index, total },
1837
+ file: cluster.files[0],
1838
+ action: "placing events"
1466
1839
  });
1467
- if (!res.ok) return [];
1468
- const body = await res.json();
1469
- return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
1470
- } catch {
1471
- return [];
1840
+ const edit = await deps.provider.invoke(
1841
+ {
1842
+ role: "edit",
1843
+ systemPrompt: deps.playbookSystemPrompt,
1844
+ userPrompt: clusterPrompt(cluster, deps.registered, bindings),
1845
+ tools: [],
1846
+ allowRepoWrite: true,
1847
+ cwd: deps.worktree.path
1848
+ },
1849
+ deps.models.edit,
1850
+ (action) => deps.sink.emit({ phase: "integrating", cluster: { index, total }, action })
1851
+ );
1852
+ if (edit.kind !== "completed") {
1853
+ const failure = edit.kind === "context_overflow" ? "context_overflow" : edit.kind === "budget_exhausted" ? "context_overflow" : classifyFailure(edit.kind === "failed" ? edit.error : "");
1854
+ const nextStatus = nextClusterStatusAfterFailure(failure, cluster.attempts);
1855
+ clusters = transitionCluster(clusters, cluster.id, nextStatus, `edit ${edit.kind}`);
1856
+ deps.saveClusters(clusters);
1857
+ for (const placement of cluster.events) {
1858
+ eventStatuses.set(placement.eventCode, "failed");
1859
+ }
1860
+ continue;
1861
+ }
1862
+ let statuses = "applied";
1863
+ if (steps.length > 0) {
1864
+ deps.sink.emit({ phase: "verifying", cluster: { index, total }, action: "running verifier" });
1865
+ const results = await runVerifySteps(deps.worktree.path, steps);
1866
+ statuses = verifiedStatus(results);
1867
+ if (results.some((result) => !result.ok) && cluster.attempts < MAX_CLUSTER_ATTEMPTS) {
1868
+ deps.sink.emit({ phase: "verifying", cluster: { index, total }, action: "repairing verification failure" });
1869
+ await deps.provider.invoke(
1870
+ {
1871
+ role: "edit",
1872
+ systemPrompt: deps.playbookSystemPrompt,
1873
+ userPrompt: [
1874
+ "The verification command failed after your edits. Fix ONLY the failure below, then stop.",
1875
+ "",
1876
+ results.map((result) => result.output).join("\n").slice(0, 2e4)
1877
+ ].join("\n"),
1878
+ tools: [],
1879
+ allowRepoWrite: true,
1880
+ cwd: deps.worktree.path
1881
+ },
1882
+ deps.models.edit,
1883
+ (action) => deps.sink.emit({ phase: "verifying", cluster: { index, total }, action })
1884
+ );
1885
+ const retried = await runVerifySteps(deps.worktree.path, steps);
1886
+ statuses = verifiedStatus(retried);
1887
+ }
1888
+ }
1889
+ clusters = transitionCluster(clusters, cluster.id, "verified");
1890
+ deps.saveClusters(clusters);
1891
+ const diff = await clusterDiff(cluster.files);
1892
+ const review = await reviewCluster(deps, cluster, diff);
1893
+ const finalStatus = review.ok ? statuses === "applied" ? "applied" : "reviewed" : statuses;
1894
+ clusters = transitionCluster(clusters, cluster.id, "reviewed", review.ok ? void 0 : review.notes.slice(0, 500));
1895
+ deps.saveClusters(clusters);
1896
+ for (const placement of cluster.events) {
1897
+ eventStatuses.set(placement.eventCode, finalStatus);
1898
+ }
1472
1899
  }
1900
+ if (!clustersComplete(clusters)) {
1901
+ throw new Error("cluster queue did not reach a terminal state");
1902
+ }
1903
+ return { clusters, eventStatuses };
1473
1904
  }
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;
1905
+ async function finalizeIntegration(deps) {
1906
+ const steps = resolveVerifySteps(deps.worktree.path, deps.target);
1907
+ deps.sink.emit({ phase: "verifying", action: "final verification pass" });
1908
+ const results = await runVerifySteps(deps.worktree.path, steps);
1909
+ return {
1910
+ results,
1911
+ status: runVerificationStatus(results),
1912
+ command: verificationCommand(results)
1913
+ };
1478
1914
  }
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),
1915
+
1916
+ // src/engine/runs.ts
1917
+ import { createHash as createHash2 } from "crypto";
1918
+ var RunsApiError = class extends Error {
1919
+ constructor(message, status, code) {
1920
+ super(message);
1921
+ this.status = status;
1922
+ this.code = code;
1923
+ this.name = "RunsApiError";
1924
+ }
1925
+ status;
1926
+ code;
1927
+ };
1928
+ function itemIdempotencyKey(kind, code, extra = "") {
1929
+ return createHash2("sha256").update(`${kind}
1930
+ ${code}
1931
+ ${extra}`).digest("hex").slice(0, 32);
1932
+ }
1933
+ var RunsClient = class {
1934
+ constructor(config, session) {
1935
+ this.config = config;
1936
+ this.session = session;
1937
+ }
1938
+ config;
1939
+ session;
1940
+ async request(method, path, body) {
1941
+ let response;
1942
+ try {
1943
+ response = await fetch(`${this.config.apiBaseUrl}${path}`, {
1944
+ method,
1487
1945
  headers: {
1488
1946
  "Content-Type": "application/json",
1489
- Authorization: `Bearer ${session.token}`
1947
+ Authorization: `Bearer ${this.session.token}`
1490
1948
  },
1491
- body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
1492
- }
1949
+ body: body === void 0 ? void 0 : JSON.stringify(body)
1950
+ });
1951
+ } catch (error) {
1952
+ throw new RunsApiError(error instanceof Error ? error.message : "request failed");
1953
+ }
1954
+ let payload;
1955
+ const text = await response.text();
1956
+ try {
1957
+ payload = text.length > 0 ? JSON.parse(text) : {};
1958
+ } catch {
1959
+ payload = {};
1960
+ }
1961
+ if (!response.ok) {
1962
+ const detail = payload;
1963
+ throw new RunsApiError(
1964
+ detail.message ?? `runs API ${method} ${path} failed`,
1965
+ response.status,
1966
+ detail.code
1967
+ );
1968
+ }
1969
+ return payload;
1970
+ }
1971
+ createOrResumeRun(input) {
1972
+ return this.request("POST", "/wizard/runs", { ...input });
1973
+ }
1974
+ getRun(runId) {
1975
+ return this.request("GET", `/wizard/runs/${runId}`);
1976
+ }
1977
+ patchRun(runId, patch) {
1978
+ return this.request("PATCH", `/wizard/runs/${runId}`, { ...patch });
1979
+ }
1980
+ writeItem(runId, kind, payload, idempotencyExtra = "") {
1981
+ const code = typeof payload.code === "string" ? payload.code : JSON.stringify(payload);
1982
+ return this.request("POST", `/wizard/runs/${runId}/items`, {
1983
+ idempotencyKey: itemIdempotencyKey(kind, code, idempotencyExtra),
1984
+ kind,
1985
+ payload
1986
+ });
1987
+ }
1988
+ completeRun(runId, completion) {
1989
+ return this.request(
1990
+ "POST",
1991
+ `/wizard/runs/${runId}/complete`,
1992
+ { ...completion }
1493
1993
  );
1494
- return res.status === 200;
1495
- } catch {
1496
- return false;
1497
1994
  }
1995
+ };
1996
+
1997
+ // src/engine/selection.ts
1998
+ import { z } from "zod";
1999
+
2000
+ // src/engine/prompts.ts
2001
+ var SURVEY_SYSTEM_PROMPT = [
2002
+ "You are the read-only survey pass of the Whisperr integration wizard.",
2003
+ "Map the repository so a later pass can pick the product's important lifecycle events.",
2004
+ "You cannot edit files. Be fast and decisive; read entry points and feature roots, not every file.",
2005
+ "",
2006
+ "Report, in plain markdown:",
2007
+ "1. Stack: frameworks, package manager, app entry point file.",
2008
+ "2. Feature areas: name, root directory, and the 1-3 files where committed user actions happen.",
2009
+ "3. The end-user identify() anchor: the login/signup success and session-restore call sites for the",
2010
+ " PAYING end user \u2014 never admin, staff, operator, or seller paths.",
2011
+ "4. Existing analytics calls, if any.",
2012
+ "5. Committed-action call sites worth instrumenting: file, function/symbol, and what the user just did.",
2013
+ "Cite concrete file paths for every claim. Do not propose event names yet."
2014
+ ].join("\n");
2015
+ function surveyUserPrompt(repoPath, onboardingContext) {
2016
+ return [
2017
+ `Survey the repository at ${repoPath}.`,
2018
+ "",
2019
+ onboardingContext ? "Business context from onboarding:\n" + onboardingContext : "",
2020
+ "",
2021
+ "Produce the survey report now."
2022
+ ].join("\n");
1498
2023
  }
1499
- function asArray(value) {
1500
- return Array.isArray(value) ? value : [];
2024
+ var SELECTION_SYSTEM_PROMPT = [
2025
+ "You are the event-selection pass of the Whisperr integration wizard.",
2026
+ "From the survey report and business context, choose the IMPORTANT product events this codebase",
2027
+ "can actually emit, and propose each one with the propose_event tool.",
2028
+ "",
2029
+ "Rules \u2014 these are hard constraints:",
2030
+ "- Only propose an event with a concrete call site: a real file and function where the committed",
2031
+ " user action happens. Never propose absence/timeout/derived concepts (daily_log_missed,",
2032
+ " streak_broken) \u2014 the server derives those later.",
2033
+ "- Emit after confirmed outcomes, not intent: a purchase event fires on confirmed success, not on",
2034
+ " button tap; denied permissions are not grants.",
2035
+ "- Importance is anchored in the business context: the activation moment, churn-risk and healthy",
2036
+ " signals, and how the product charges. A handful of load-bearing events beats wide coverage.",
2037
+ "- An empty or small set is a valid outcome. There is no quota. Never invent events to look thorough.",
2038
+ "- Codes are lowercase snake_case, named for what happened (checkout_completed, not do_checkout).",
2039
+ "- payloadSchema lists the fields worth capturing with a short description of each \u2014 the important",
2040
+ " info only. NEVER include personal identifiers: no email, phone, names, addresses, birth dates,",
2041
+ " government ids, usernames, passwords, device ids, or IP addresses. Domain metrics are welcome.",
2042
+ "- The end user is the paying customer/consumer. Never instrument admin, staff, or operator actions.",
2043
+ "",
2044
+ "Call propose_event once per event. When done, call finish_selection with a one-line summary."
2045
+ ].join("\n");
2046
+ function selectionUserPrompt(surveyReport, onboardingContext, existingEventCodes, rejectedCodes) {
2047
+ return [
2048
+ "Survey report:",
2049
+ surveyReport,
2050
+ "",
2051
+ onboardingContext ? "Business context from onboarding:\n" + onboardingContext : "",
2052
+ existingEventCodes.length > 0 ? "Already registered for this app (do not re-propose; other projects may own them):\n- " + existingEventCodes.join("\n- ") : "",
2053
+ rejectedCodes.length > 0 ? "Previously rejected by the user (do not re-propose):\n- " + rejectedCodes.join("\n- ") : "",
2054
+ "",
2055
+ "Propose the important events now."
2056
+ ].filter((section) => section !== "").join("\n");
1501
2057
  }
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;
2058
+ function renderOnboardingContext(context) {
2059
+ if (!context) {
2060
+ return "";
2061
+ }
2062
+ const rendered = JSON.stringify(context, null, 1);
2063
+ return rendered.length > 6e3 ? rendered.slice(0, 6e3) + "\n\u2026(truncated)" : rendered;
1506
2064
  }
1507
- function asConfidence(value) {
1508
- if (typeof value !== "number" || Number.isNaN(value)) return void 0;
1509
- return Math.min(1, Math.max(0, value));
2065
+
2066
+ // src/engine/selection.ts
2067
+ var PII_SEGMENTS = /* @__PURE__ */ new Set([
2068
+ "email",
2069
+ "phone",
2070
+ "address",
2071
+ "surname",
2072
+ "dob",
2073
+ "birthday",
2074
+ "birthdate",
2075
+ "ssn",
2076
+ "passport",
2077
+ "password",
2078
+ "username"
2079
+ ]);
2080
+ var PII_PAIRS = [
2081
+ ["first", "name"],
2082
+ ["last", "name"],
2083
+ ["full", "name"],
2084
+ ["user", "name"],
2085
+ ["middle", "name"],
2086
+ ["maiden", "name"],
2087
+ ["birth", "date"],
2088
+ ["date", "birth"],
2089
+ ["national", "id"],
2090
+ ["government", "id"],
2091
+ ["device", "id"],
2092
+ ["personal", "number"],
2093
+ ["social", "security"],
2094
+ ["tax", "id"],
2095
+ ["drivers", "license"]
2096
+ ];
2097
+ function isPIIField(name) {
2098
+ const segments = name.split("_");
2099
+ if (segments.some((segment) => PII_SEGMENTS.has(segment))) {
2100
+ return true;
2101
+ }
2102
+ for (let index = 0; index + 1 < segments.length; index += 1) {
2103
+ if (PII_PAIRS.some(([a, b]) => segments[index] === a && segments[index + 1] === b)) {
2104
+ return true;
2105
+ }
2106
+ }
2107
+ return false;
1510
2108
  }
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;
2109
+ function normalizeEventCode(value) {
2110
+ const collapsed = value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_{2,}/g, "_");
2111
+ return /^[a-z]/.test(collapsed) ? collapsed : "";
1515
2112
  }
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;
2113
+ function validateProposedEvent(input, seen, rejected) {
2114
+ const code = normalizeEventCode(input.code);
2115
+ if (code === "") {
2116
+ return { ok: false, reason: `event code ${JSON.stringify(input.code)} is not snake_case` };
2117
+ }
2118
+ if (seen.has(code)) {
2119
+ return { ok: false, reason: `event ${code} was already proposed` };
2120
+ }
2121
+ if (rejected.has(code)) {
2122
+ return { ok: false, reason: `event ${code} was rejected by the user \u2014 do not re-propose it` };
2123
+ }
2124
+ if (!input.name.trim() || !input.reasoning.trim() || !input.anchorFile.trim()) {
2125
+ return { ok: false, reason: "name, reasoning, and anchorFile are required" };
2126
+ }
2127
+ const schema = {};
2128
+ for (const [field, description] of Object.entries(input.payloadSchema ?? {})) {
2129
+ const normalizedField = normalizeEventCode(field);
2130
+ if (normalizedField === "" || !description.trim()) {
2131
+ return { ok: false, reason: `payload field ${JSON.stringify(field)} needs a snake_case name and a description` };
1526
2132
  }
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
- });
2133
+ if (isPIIField(normalizedField)) {
2134
+ return { ok: false, reason: `payload field ${normalizedField} is a personal identifier and is not allowed` };
2135
+ }
2136
+ schema[normalizedField] = description.trim();
1537
2137
  }
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 } : {} });
2138
+ if (Object.keys(schema).length > 20) {
2139
+ return { ok: false, reason: "payload schema exceeds 20 fields" };
1546
2140
  }
1547
- const side = normalizeSide(asString(e.side));
1548
2141
  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 } : {}
2142
+ ok: true,
2143
+ event: {
2144
+ code,
2145
+ name: input.name.trim(),
2146
+ reasoning: input.reasoning.trim(),
2147
+ anchorFile: input.anchorFile.trim(),
2148
+ anchorSymbol: input.anchorSymbol?.trim() || void 0,
2149
+ payloadSchema: schema
2150
+ }
1558
2151
  };
1559
2152
  }
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
- };
2153
+ async function runSurvey(provider, models, repoPath, bootstrap, sink, resumeSessionId) {
2154
+ sink.emit({ phase: "surveying", action: "mapping the repository" });
2155
+ const outcome = await provider.invoke(
2156
+ {
2157
+ role: "survey",
2158
+ systemPrompt: SURVEY_SYSTEM_PROMPT,
2159
+ userPrompt: surveyUserPrompt(repoPath, renderOnboardingContext(bootstrap.onboardingContext)),
2160
+ tools: [],
2161
+ allowRepoWrite: false,
2162
+ cwd: repoPath,
2163
+ resumeSessionId
2164
+ },
2165
+ models.survey,
2166
+ (action) => sink.emit({ phase: "surveying", action })
2167
+ );
2168
+ return { report: outcome.kind === "completed" ? outcome.text : "", outcome };
1583
2169
  }
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;
2170
+ async function runSelection(provider, models, repoPath, bootstrap, surveyReport, rejectedCodes, sink) {
2171
+ const proposed = [];
2172
+ const seen = /* @__PURE__ */ new Set();
2173
+ const rejected = new Set(rejectedCodes);
2174
+ let summary = "";
2175
+ const proposeTool = {
2176
+ name: "propose_event",
2177
+ description: "Propose one important, concretely-anchored product event. Returns ok:true when accepted.",
2178
+ inputSchema: {
2179
+ code: z.string().describe("lowercase snake_case event code"),
2180
+ name: z.string().describe("short human-readable name"),
2181
+ reasoning: z.string().describe("why this event matters for this business"),
2182
+ anchorFile: z.string().describe("repository file where the committed action happens"),
2183
+ anchorSymbol: z.string().optional().describe("function/symbol at the anchor"),
2184
+ payloadSchema: z.record(z.string(), z.string()).optional().describe("important payload fields: name -> short description; no personal identifiers")
2185
+ },
2186
+ jsonSchema: {
2187
+ type: "object",
2188
+ properties: {
2189
+ code: { type: "string", description: "lowercase snake_case event code" },
2190
+ name: { type: "string", description: "short human-readable name" },
2191
+ reasoning: { type: "string", description: "why this event matters for this business" },
2192
+ anchorFile: { type: "string", description: "repository file where the committed action happens" },
2193
+ anchorSymbol: { type: "string", description: "function/symbol at the anchor" },
2194
+ payloadSchema: {
2195
+ type: "object",
2196
+ additionalProperties: { type: "string" },
2197
+ description: "important payload fields: name -> short description; no personal identifiers"
2198
+ }
2199
+ },
2200
+ required: ["code", "name", "reasoning", "anchorFile"]
2201
+ },
2202
+ handler: async (input) => {
2203
+ const candidate = {
2204
+ code: String(input.code ?? ""),
2205
+ name: String(input.name ?? ""),
2206
+ reasoning: String(input.reasoning ?? ""),
2207
+ anchorFile: String(input.anchorFile ?? ""),
2208
+ anchorSymbol: input.anchorSymbol === void 0 ? void 0 : String(input.anchorSymbol),
2209
+ payloadSchema: input.payloadSchema ?? {}
2210
+ };
2211
+ const validated = validateProposedEvent(candidate, seen, rejected);
2212
+ if (!validated.ok) {
2213
+ return { ok: false, reason: validated.reason };
2214
+ }
2215
+ seen.add(validated.event.code);
2216
+ proposed.push(validated.event);
2217
+ sink.emit({
2218
+ phase: "designing",
2219
+ file: validated.event.anchorFile,
2220
+ action: `proposed ${validated.event.code}`
2221
+ });
2222
+ return { ok: true, code: validated.event.code };
2223
+ }
2224
+ };
2225
+ const finishTool = {
2226
+ name: "finish_selection",
2227
+ description: "Signal that every important event has been proposed.",
2228
+ inputSchema: { summary: z.string().describe("one line on what was selected and why") },
2229
+ jsonSchema: {
2230
+ type: "object",
2231
+ properties: { summary: { type: "string", description: "one line on what was selected and why" } },
2232
+ required: ["summary"]
2233
+ },
2234
+ handler: async (input) => {
2235
+ summary = String(input.summary ?? "");
2236
+ return { ok: true };
2237
+ }
2238
+ };
2239
+ sink.emit({ phase: "designing", action: "selecting important events" });
2240
+ const outcome = await provider.invoke(
2241
+ {
2242
+ role: "design",
2243
+ systemPrompt: SELECTION_SYSTEM_PROMPT,
2244
+ userPrompt: selectionUserPrompt(
2245
+ surveyReport,
2246
+ renderOnboardingContext(bootstrap.onboardingContext),
2247
+ bootstrap.snapshot.events.map((event) => event.code),
2248
+ rejectedCodes
2249
+ ),
2250
+ tools: [proposeTool, finishTool],
2251
+ allowRepoWrite: false,
2252
+ cwd: repoPath
2253
+ },
2254
+ models.design,
2255
+ (action) => sink.emit({ phase: "designing", action })
2256
+ );
2257
+ return {
2258
+ outcome: outcome.kind,
2259
+ proposed,
2260
+ summary,
2261
+ surveyReport,
2262
+ sessionId: outcome.sessionId
2263
+ };
2264
+ }
2265
+ async function registerApprovedEvents(runs, runId, approved, sink) {
2266
+ const registered = [];
2267
+ for (const event of approved) {
2268
+ const result = await runs.writeItem(runId, "event", {
2269
+ code: event.code,
2270
+ name: event.name,
2271
+ reasoning: event.reasoning,
2272
+ ...Object.keys(event.payloadSchema).length > 0 ? { payloadSchema: event.payloadSchema } : {}
2273
+ });
2274
+ const item = result.item;
2275
+ registered.push({ ...event, eventId: item?.id ?? result.id ?? "" });
2276
+ sink.emit({ phase: "persisting", file: event.anchorFile, action: `registered ${event.code}` });
2277
+ }
2278
+ return registered;
2279
+ }
2280
+ function placementsFor(registered) {
2281
+ return registered.map((event) => ({
2282
+ eventId: event.eventId,
2283
+ eventCode: event.code,
2284
+ file: event.anchorFile,
2285
+ symbol: event.anchorSymbol,
2286
+ status: "planned"
2287
+ }));
2288
+ }
2289
+
2290
+ // src/engine/state.ts
2291
+ import { createHash as createHash3 } from "crypto";
2292
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "fs";
2293
+ import { homedir } from "os";
2294
+ import { join as join4 } from "path";
2295
+
2296
+ // src/engine/types.ts
2297
+ var ENGINE_PHASES = [
2298
+ "authorizing",
2299
+ "surveying",
2300
+ "designing",
2301
+ "persisting",
2302
+ "binding",
2303
+ "integrating",
2304
+ "reviewing",
2305
+ "verifying",
2306
+ "reporting",
2307
+ "completed"
2308
+ ];
2309
+
2310
+ // src/engine/state.ts
2311
+ function stateDirectory() {
2312
+ const override = process.env.WHISPERR_WIZARD_STATE_DIR?.trim();
2313
+ if (override) {
2314
+ return override;
2315
+ }
2316
+ return join4(homedir(), ".whisperr", "wizard-runs");
2317
+ }
2318
+ function stateKey(apiBaseUrl, appId, repoFingerprint2) {
2319
+ return createHash3("sha256").update(`${apiBaseUrl}
2320
+ ${appId}
2321
+ ${repoFingerprint2}`).digest("hex").slice(0, 24);
2322
+ }
2323
+ function statePath(apiBaseUrl, appId, repoFingerprint2) {
2324
+ return join4(stateDirectory(), `${stateKey(apiBaseUrl, appId, repoFingerprint2)}.json`);
2325
+ }
2326
+ function saveRunState(state) {
2327
+ const path = statePath(state.apiBaseUrl, state.appId, state.repoFingerprint);
2328
+ mkdirSync(stateDirectory(), { recursive: true, mode: 448 });
2329
+ const payload = JSON.stringify({ ...state, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2);
2330
+ const temporary = `${path}.tmp`;
2331
+ writeFileSync(temporary, payload, { mode: 384 });
2332
+ renameSync(temporary, path);
2333
+ }
2334
+ function loadRunState(apiBaseUrl, appId, repoFingerprint2) {
2335
+ const path = statePath(apiBaseUrl, appId, repoFingerprint2);
2336
+ let raw;
2337
+ try {
2338
+ raw = readFileSync2(path, "utf8");
2339
+ } catch {
2340
+ return void 0;
2341
+ }
2342
+ let parsed;
2343
+ try {
2344
+ parsed = JSON.parse(raw);
2345
+ } catch {
2346
+ return void 0;
2347
+ }
2348
+ if (!isEngineRunState(parsed)) {
2349
+ return void 0;
2350
+ }
2351
+ if (parsed.apiBaseUrl !== apiBaseUrl || parsed.appId !== appId || parsed.repoFingerprint !== repoFingerprint2) {
2352
+ return void 0;
2353
+ }
2354
+ return parsed;
2355
+ }
2356
+ function isEngineRunState(value) {
2357
+ if (typeof value !== "object" || value === null) {
2358
+ return false;
2359
+ }
2360
+ const candidate = value;
2361
+ return candidate.version === 1 && typeof candidate.apiBaseUrl === "string" && typeof candidate.appId === "string" && typeof candidate.repoFingerprint === "string" && typeof candidate.runId === "string" && typeof candidate.phase === "string" && ENGINE_PHASES.includes(candidate.phase) && typeof candidate.designComplete === "boolean" && Array.isArray(candidate.tombstonedCodes) && Array.isArray(candidate.clusters) && typeof candidate.providerSessions === "object" && candidate.providerSessions !== null;
2362
+ }
2363
+
2364
+ // src/engine/worktree.ts
2365
+ import { spawn as spawn2 } from "child_process";
2366
+ import { mkdtemp, writeFile as writeFile2 } from "fs/promises";
2367
+ import { tmpdir } from "os";
2368
+ import { join as join5 } from "path";
2369
+ var WorktreeError = class extends Error {
2370
+ constructor(code, message) {
2371
+ super(message);
2372
+ this.code = code;
2373
+ this.name = "WorktreeError";
2374
+ }
2375
+ code;
2376
+ };
2377
+ function git(cwd, args) {
2378
+ return new Promise((resolve4) => {
2379
+ const child = spawn2("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
2380
+ let stdout = "";
2381
+ let stderr = "";
2382
+ child.stdout.on("data", (chunk) => {
2383
+ stdout += String(chunk);
2384
+ });
2385
+ child.stderr.on("data", (chunk) => {
2386
+ stderr += String(chunk);
2387
+ });
2388
+ child.on("error", () => resolve4({ ok: false, stdout, stderr: "git is not available" }));
2389
+ child.on("close", (exitCode) => resolve4({ ok: exitCode === 0, stdout, stderr }));
2390
+ });
2391
+ }
2392
+ async function must(cwd, args) {
2393
+ const result = await git(cwd, args);
2394
+ if (!result.ok) {
2395
+ throw new WorktreeError("git_failed", `git ${args.join(" ")}: ${result.stderr.trim()}`);
2396
+ }
2397
+ return result.stdout;
2398
+ }
2399
+ async function createWorktree(repoPath, force = false) {
2400
+ const inside = await git(repoPath, ["rev-parse", "--is-inside-work-tree"]);
2401
+ if (!inside.ok) {
2402
+ throw new WorktreeError("not_a_repo", "the target directory is not a git repository");
2403
+ }
2404
+ const status = await must(repoPath, ["status", "--porcelain"]);
2405
+ if (status.trim() !== "" && !force) {
2406
+ throw new WorktreeError(
2407
+ "dirty_repo",
2408
+ "the repository has uncommitted changes \u2014 commit or stash them, or rerun with --force"
2409
+ );
2410
+ }
2411
+ const baseRef = (await must(repoPath, ["rev-parse", "HEAD"])).trim();
2412
+ const path = await mkdtemp(join5(tmpdir(), "whisperr-worktree-"));
2413
+ await must(repoPath, ["worktree", "add", "--detach", "--force", path, "HEAD"]);
2414
+ return { path, baseRef, repoPath };
2415
+ }
2416
+ async function worktreePatch(handle) {
2417
+ await must(handle.path, ["add", "-A"]);
2418
+ return must(handle.path, ["diff", "--cached", "--binary", handle.baseRef]);
2419
+ }
2420
+ async function applyPatchToRepo(handle, patch) {
2421
+ if (patch.trim() === "") {
2422
+ return;
2423
+ }
2424
+ await new Promise((resolve4, reject) => {
2425
+ const child = spawn2("git", ["apply", "--whitespace=nowarn"], {
2426
+ cwd: handle.repoPath,
2427
+ stdio: ["pipe", "ignore", "pipe"]
2428
+ });
2429
+ let stderr = "";
2430
+ child.stderr.on("data", (chunk) => {
2431
+ stderr += String(chunk);
2432
+ });
2433
+ child.on("error", (error) => reject(new WorktreeError("git_failed", String(error))));
2434
+ child.on("close", (exitCode) => {
2435
+ if (exitCode === 0) {
2436
+ resolve4();
2437
+ } else {
2438
+ reject(new WorktreeError("git_failed", `git apply failed: ${stderr.trim()}`));
2439
+ }
2440
+ });
2441
+ child.stdin.end(patch);
2442
+ });
2443
+ }
2444
+ async function savePartialPatch(handle, destination) {
2445
+ const patch = await worktreePatch(handle);
2446
+ if (patch.trim() === "") {
2447
+ return void 0;
2448
+ }
2449
+ await writeFile2(destination, patch, { mode: 384 });
2450
+ return destination;
2451
+ }
2452
+ async function removeWorktree(handle) {
2453
+ await git(handle.repoPath, ["worktree", "remove", "--force", handle.path]);
2454
+ await git(handle.repoPath, ["worktree", "prune"]);
2455
+ }
2456
+
2457
+ // src/engine/orchestrator.ts
2458
+ async function runEngine(input) {
2459
+ const runs = new RunsClient(input.config, input.session);
2460
+ let bootstrap;
2461
+ try {
2462
+ bootstrap = await runs.createOrResumeRun({
2463
+ repoFingerprint: input.repoFingerprint,
2464
+ displayName: basename(input.repoPath),
2465
+ target: input.target,
2466
+ kind: input.projectKind
2467
+ });
2468
+ } catch (error) {
2469
+ if (error instanceof RunsApiError && error.code === "wrong_universe_mode") {
2470
+ return { kind: "wrong_mode" };
2471
+ }
2472
+ throw error;
2473
+ }
2474
+ const runId = bootstrap.run.id;
2475
+ input.provider.onRunReady?.(runId);
2476
+ const patchPhase = async (phase, message) => {
2477
+ try {
2478
+ await runs.patchRun(runId, { phase, ...message ? { message } : {} });
2479
+ } catch {
2480
+ }
2481
+ };
2482
+ const failRun = async (error) => {
2483
+ try {
2484
+ await runs.patchRun(runId, { status: "failed", error: error.slice(0, 500) });
2485
+ } catch {
2486
+ }
2487
+ };
2488
+ let state = loadRunState(input.config.apiBaseUrl, input.session.appId, input.repoFingerprint) ?? freshState(input, runId);
2489
+ if (state.runId !== runId) {
2490
+ state = freshState(input, runId);
2491
+ }
2492
+ const persist = () => saveRunState(state);
2493
+ let registered;
2494
+ if (!state.designComplete) {
2495
+ await patchPhase("surveying");
2496
+ const survey = await runSurvey(
2497
+ input.provider,
2498
+ input.models,
2499
+ input.repoPath,
2500
+ bootstrap,
2501
+ input.sink,
2502
+ state.providerSessions.survey
2503
+ );
2504
+ if (survey.outcome.kind !== "completed") {
2505
+ await failRun(`survey ${survey.outcome.kind}`);
2506
+ return { kind: "aborted", reason: `survey ${survey.outcome.kind}` };
2507
+ }
2508
+ state.providerSessions.survey = survey.outcome.sessionId;
2509
+ persist();
2510
+ await patchPhase("designing");
2511
+ const selection = await runSelection(
2512
+ input.provider,
2513
+ input.models,
2514
+ input.repoPath,
2515
+ bootstrap,
2516
+ survey.report,
2517
+ state.tombstonedCodes,
2518
+ input.sink
2519
+ );
2520
+ if (selection.outcome !== "completed" && selection.proposed.length === 0) {
2521
+ await failRun(`selection ${selection.outcome}`);
2522
+ return { kind: "aborted", reason: `selection ${selection.outcome}` };
2523
+ }
2524
+ const approved = await input.callbacks.reviewEvents(selection.proposed);
2525
+ if (approved === null) {
2526
+ await failRun("selection rejected by user");
2527
+ return { kind: "aborted", reason: "selection rejected by user" };
2528
+ }
2529
+ const approvedCodes = new Set(approved.map((event) => event.code));
2530
+ for (const event of selection.proposed) {
2531
+ if (!approvedCodes.has(event.code) && !state.tombstonedCodes.includes(event.code)) {
2532
+ state.tombstonedCodes.push(event.code);
2533
+ }
2534
+ }
2535
+ persist();
2536
+ await patchPhase("persisting", `registering ${approved.length} events`);
2537
+ try {
2538
+ registered = await registerApprovedEvents(runs, runId, approved, input.sink);
2539
+ } catch (error) {
2540
+ const reason = error instanceof Error ? error.message : String(error);
2541
+ await failRun(`event registration failed: ${reason}`);
2542
+ persist();
2543
+ return { kind: "aborted", reason: `event registration failed: ${reason}` };
2544
+ }
2545
+ state.designComplete = true;
2546
+ state.clusters = deriveClusters(placementsFor(registered));
2547
+ persist();
2548
+ } else {
2549
+ registered = rebuildRegistered(state, bootstrap);
2550
+ input.sink.emit({ phase: "designing", action: `resumed with ${registered.length} registered events` });
2551
+ }
2552
+ if (registered.length === 0) {
2553
+ await patchPhase("reporting", "no events registered for this project");
2554
+ await completeRun(runs, runId, [], /* @__PURE__ */ new Map(), [], true, "unavailable", void 0);
2555
+ return {
2556
+ kind: "completed",
2557
+ runId,
2558
+ registered,
2559
+ eventStatuses: /* @__PURE__ */ new Map(),
2560
+ reportEvents: [],
2561
+ changedFiles: [],
2562
+ identifyWired: false,
2563
+ applied: false,
2564
+ summary: "No important events found for this project; nothing was instrumented."
2565
+ };
2566
+ }
2567
+ await patchPhase("binding");
2568
+ let worktree;
2569
+ try {
2570
+ worktree = await createWorktree(input.repoPath, input.force ?? false);
2571
+ } catch (error) {
2572
+ const reason = error instanceof Error ? error.message : String(error);
2573
+ await failRun(reason);
2574
+ return { kind: "aborted", reason };
2575
+ }
2576
+ state.worktreePath = worktree.path;
2577
+ persist();
2578
+ try {
2579
+ const bindings = await writeBindingsModule(worktree, input.target, registered);
2580
+ const deps = {
2581
+ provider: input.provider,
2582
+ models: input.models,
2583
+ worktree,
2584
+ target: input.target,
2585
+ playbookSystemPrompt: input.playbookSystemPrompt,
2586
+ registered,
2587
+ ingestion: bootstrap.ingestion,
2588
+ sink: input.sink,
2589
+ saveClusters: (clusters) => {
2590
+ state.clusters = clusters;
2591
+ persist();
2592
+ }
2593
+ };
2594
+ const identifyWired = await runSdkSetupPass(deps, bindings);
2595
+ await patchPhase("integrating");
2596
+ const integration = await runClusterIntegration(
2597
+ deps,
2598
+ state.clusters,
2599
+ async () => worktreePatch(worktree)
2600
+ );
2601
+ state.clusters = integration.clusters;
2602
+ persist();
2603
+ const finalVerify = await finalizeIntegration(deps);
2604
+ const patch = await worktreePatch(worktree);
2605
+ const changedFiles2 = patchFiles(patch);
2606
+ await patchPhase("reporting");
2607
+ const evidence = buildEvidence(registered, integration.eventStatuses, changedFiles2);
2608
+ await completeRun(
2609
+ runs,
2610
+ runId,
2611
+ evidence,
2612
+ integration.eventStatuses,
2613
+ changedFiles2,
2614
+ identifyWired,
2615
+ finalVerify.status,
2616
+ finalVerify.command
2617
+ );
2618
+ const summaryLines = summarize(registered, integration.eventStatuses);
2619
+ const applied = patch.trim() !== "" && await input.callbacks.confirmApply(patch, summaryLines);
2620
+ if (applied) {
2621
+ await applyPatchToRepo(worktree, patch);
2622
+ }
2623
+ const partialPatchPath = applied ? void 0 : await savePartialPatch(worktree, join6(input.repoPath, ".whisperr-wizard.patch"));
2624
+ await removeWorktree(worktree);
2625
+ state.worktreePath = void 0;
2626
+ persist();
2627
+ const hasFailures = [...integration.eventStatuses.values()].some(
2628
+ (status) => status === "failed"
2629
+ );
2630
+ return {
2631
+ kind: hasFailures ? "partial" : "completed",
2632
+ runId,
2633
+ registered,
2634
+ eventStatuses: integration.eventStatuses,
2635
+ reportEvents: buildReportEvents(registered, integration.eventStatuses),
2636
+ changedFiles: changedFiles2,
2637
+ identifyWired,
2638
+ applied,
2639
+ partialPatchPath: partialPatchPath ?? void 0,
2640
+ summary: summaryLines.join("\n")
2641
+ };
2642
+ } catch (error) {
2643
+ const reason = error instanceof Error ? error.message : String(error);
2644
+ await failRun(reason);
2645
+ const partialPatchPath = await savePartialPatch(
2646
+ worktree,
2647
+ join6(input.repoPath, ".whisperr-wizard.patch")
2648
+ ).catch(() => void 0);
2649
+ persist();
2650
+ return {
2651
+ kind: "partial",
2652
+ runId,
2653
+ registered,
2654
+ eventStatuses: /* @__PURE__ */ new Map(),
2655
+ reportEvents: buildReportEvents(registered, /* @__PURE__ */ new Map()),
2656
+ changedFiles: [],
2657
+ identifyWired: false,
2658
+ applied: false,
2659
+ partialPatchPath: partialPatchPath ?? void 0,
2660
+ summary: `Integration stopped early: ${reason}. Registered events are saved; rerun to resume.`
2661
+ };
2662
+ }
2663
+ }
2664
+ function freshState(input, runId) {
2665
+ return {
2666
+ version: 1,
2667
+ apiBaseUrl: input.config.apiBaseUrl,
2668
+ appId: input.session.appId,
2669
+ repoFingerprint: input.repoFingerprint,
2670
+ runId,
2671
+ phase: "authorizing",
2672
+ providerSessions: {},
2673
+ designComplete: false,
2674
+ tombstonedCodes: [],
2675
+ clusters: [],
2676
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2677
+ };
2678
+ }
2679
+ function rebuildRegistered(state, bootstrap) {
2680
+ const byCode = new Map(bootstrap.snapshot.events.map((event) => [event.code, event]));
2681
+ const registered = [];
2682
+ for (const cluster of state.clusters) {
2683
+ for (const placement of cluster.events) {
2684
+ const event = byCode.get(placement.eventCode);
2685
+ if (!event) {
2686
+ continue;
2687
+ }
2688
+ registered.push({
2689
+ code: event.code,
2690
+ name: event.name,
2691
+ reasoning: event.reasoning,
2692
+ eventId: event.id,
2693
+ anchorFile: placement.file,
2694
+ anchorSymbol: placement.symbol,
2695
+ payloadSchema: event.payloadSchema ?? {}
2696
+ });
2697
+ }
2698
+ }
2699
+ return registered;
2700
+ }
2701
+ function buildEvidence(registered, statuses, changedFiles2) {
2702
+ const changed = new Set(changedFiles2);
2703
+ return registered.map((event) => {
2704
+ const status = statuses.get(event.code) ?? "planned";
2705
+ const needsFile = status !== "planned" && status !== "unsupported" && status !== "failed";
2706
+ const file = changed.has(event.anchorFile) ? event.anchorFile : changedFiles2[0];
2707
+ return {
2708
+ eventId: event.eventId,
2709
+ eventCode: event.code,
2710
+ ...needsFile && file ? { file } : {},
2711
+ status
2712
+ };
2713
+ });
2714
+ }
2715
+ async function completeRun(runs, runId, evidence, _statuses, changedFiles2, identifyWired, verificationStatus, verificationCommand2) {
2716
+ try {
2717
+ await runs.completeRun(runId, {
2718
+ changedFiles: changedFiles2,
2719
+ identifyWired,
2720
+ verificationStatus,
2721
+ ...verificationCommand2 ? { verificationCommand: verificationCommand2 } : {},
2722
+ events: evidence
2723
+ });
2724
+ } catch {
2725
+ }
2726
+ }
2727
+ function buildReportEvents(registered, statuses) {
2728
+ return registered.map((event) => {
2729
+ const status = statuses.get(event.code);
2730
+ const wired = status !== void 0 && status !== "failed" && status !== "unsupported" && status !== "planned";
2731
+ return {
2732
+ event_type: event.code,
2733
+ status: wired ? "wired" : "skipped",
2734
+ ...wired ? { file: event.anchorFile } : {},
2735
+ ...wired ? {} : { reason: status ?? "not integrated" }
2736
+ };
2737
+ });
2738
+ }
2739
+ function summarize(registered, statuses) {
2740
+ return registered.map((event) => {
2741
+ const status = statuses.get(event.code) ?? "planned";
2742
+ return `${event.code}: ${status} (${event.anchorFile})`;
2743
+ });
2744
+ }
2745
+ function patchFiles(patch) {
2746
+ const files = /* @__PURE__ */ new Set();
2747
+ for (const line of patch.split("\n")) {
2748
+ const match = /^diff --git a\/(.+?) b\//.exec(line);
2749
+ if (match?.[1]) {
2750
+ files.add(match[1]);
2751
+ }
2752
+ }
2753
+ return [...files];
2754
+ }
2755
+
2756
+ // src/engine/providers/claude.ts
2757
+ import { createSdkMcpServer, query as query2, tool } from "@anthropic-ai/claude-agent-sdk";
2758
+
2759
+ // src/core/agent.ts
2760
+ import {
2761
+ query
2762
+ } from "@anthropic-ai/claude-agent-sdk";
2763
+
2764
+ // src/core/git.ts
2765
+ import { spawn as spawn3 } from "child_process";
2766
+ import { createHash as createHash4 } from "crypto";
2767
+ import { readFile as readFile2 } from "fs/promises";
2768
+ import { basename as basename2, join as join7 } from "path";
2769
+ async function takeCheckpoint(repoPath) {
2770
+ const isRepo = (await run(repoPath, ["rev-parse", "--is-inside-work-tree"])).ok;
2771
+ if (!isRepo) return { isRepo: false };
2772
+ const branch = (await run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
2773
+ const head = await run(repoPath, ["rev-parse", "HEAD"]);
2774
+ return {
2775
+ isRepo: true,
2776
+ baseRef: head.ok ? head.stdout.trim() : void 0,
2777
+ branch: branch || void 0
2778
+ };
2779
+ }
2780
+ async function changedFiles(repoPath, checkpoint) {
2781
+ if (!checkpoint.isRepo) return [];
2782
+ const tracked = await run(repoPath, ["diff", "--name-only"]);
2783
+ const untracked = await run(repoPath, [
2784
+ "ls-files",
2785
+ "--others",
2786
+ "--exclude-standard"
2787
+ ]);
2788
+ const set = /* @__PURE__ */ new Set();
2789
+ for (const f of `${tracked.stdout}
2790
+ ${untracked.stdout}`.split("\n")) {
2791
+ if (f.trim()) set.add(f.trim());
2792
+ }
2793
+ return [...set];
2794
+ }
2795
+ function revertHint(checkpoint) {
2796
+ if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
2797
+ return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
2798
+ }
2799
+ async function isWorkingTreeClean(repoPath) {
2800
+ const status = await run(repoPath, ["status", "--porcelain"]);
2801
+ return status.ok && status.stdout.trim() === "";
2802
+ }
2803
+ async function revertToCheckpoint(repoPath, checkpoint) {
2804
+ if (!checkpoint.isRepo) return false;
2805
+ if (checkpoint.baseRef) {
2806
+ const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
2807
+ const clean2 = await run(repoPath, ["clean", "-fd"]);
2808
+ return reset.ok && clean2.ok;
2809
+ }
2810
+ const restore = await run(repoPath, ["restore", "."]);
2811
+ const clean = await run(repoPath, ["clean", "-fd"]);
2812
+ return restore.ok && clean.ok;
2813
+ }
2814
+ async function repoFingerprint(repoPath) {
2815
+ const remote = await run(repoPath, ["remote", "get-url", "origin"]);
2816
+ const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename2(repoPath)}`;
2817
+ return createHash4("sha256").update(seed).digest("hex").slice(0, 16);
2818
+ }
2819
+ function normalizeRemote(url) {
2820
+ return url.replace(/^git@([^:]+):/, "$1/").replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
2821
+ }
2822
+ async function scanWiredEvents(repoPath, files, eventTypes) {
2823
+ const wired = /* @__PURE__ */ new Map();
2824
+ const patterns = eventTypes.map((e) => ({
2825
+ eventType: e,
2826
+ trackRe: new RegExp(
2827
+ `\\btrack\\s*\\(\\s*[\\s\\S]{0,160}?['"\`]${escapeRegExp(e)}['"\`]`
2828
+ ),
2829
+ literalRe: quotedLiteralRegExp(e)
2830
+ }));
2831
+ const contents = [];
2832
+ for (const file of files) {
2833
+ try {
2834
+ contents.push({ file, content: await readFile2(join7(repoPath, file), "utf8") });
2835
+ } catch {
2836
+ continue;
2837
+ }
2838
+ }
2839
+ for (const { file, content } of contents) {
2840
+ for (const { eventType, trackRe } of patterns) {
2841
+ if (!wired.has(eventType) && trackRe.test(content)) {
2842
+ wired.set(eventType, file);
2843
+ }
2844
+ }
2845
+ }
2846
+ for (const { file, content } of contents) {
2847
+ for (const { eventType, literalRe } of patterns) {
2848
+ if (!wired.has(eventType) && literalRe.test(content)) {
2849
+ wired.set(eventType, file);
2850
+ }
2851
+ }
1594
2852
  }
2853
+ return wired;
2854
+ }
2855
+ function quotedLiteralRegExp(eventType) {
2856
+ const leftBoundary = /^\w/.test(eventType) ? "\\b" : "";
2857
+ const rightBoundary = /\w$/.test(eventType) ? "\\b" : "";
2858
+ return new RegExp(`(['"\`])${leftBoundary}${escapeRegExp(eventType)}${rightBoundary}\\1`);
2859
+ }
2860
+ function escapeRegExp(s) {
2861
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2862
+ }
2863
+ function run(cwd, args) {
2864
+ return new Promise((resolve4) => {
2865
+ const child = spawn3("git", args, { cwd });
2866
+ let stdout = "";
2867
+ let stderr = "";
2868
+ child.stdout.on("data", (d) => stdout += d.toString());
2869
+ child.stderr.on("data", (d) => stderr += d.toString());
2870
+ child.on("close", (code) => resolve4({ ok: code === 0, stdout, stderr }));
2871
+ child.on("error", () => resolve4({ ok: false, stdout, stderr }));
2872
+ });
1595
2873
  }
1596
2874
 
1597
2875
  // src/core/playbooks/shared-prompt.ts
@@ -1761,64 +3039,60 @@ function renderEventsBrief(m) {
1761
3039
  if (e.description) lines.push(` ${e.description}`);
1762
3040
  if (e.properties?.length) {
1763
3041
  lines.push(" properties to capture if available:");
1764
- for (const p2 of e.properties) {
1765
- const req = p2.required ? " (required)" : "";
1766
- const desc = p2.description ? ` \u2014 ${p2.description}` : "";
1767
- lines.push(` \xB7 ${p2.name}${req}${desc}`);
3042
+ for (const p3 of e.properties) {
3043
+ const req = p3.required ? " (required)" : "";
3044
+ const desc = p3.description ? ` \u2014 ${p3.description}` : "";
3045
+ lines.push(` \xB7 ${p3.name}${req}${desc}`);
1768
3046
  }
1769
3047
  }
1770
3048
  }
1771
3049
  return lines.join("\n");
1772
3050
  }
1773
- function renderOpportunitiesBrief(m, opportunitiesFile) {
3051
+ function renderOpportunitiesBrief(m) {
1774
3052
  const eventCodes = m.events.map((e) => e.eventType);
1775
3053
  const interventionCodes = [
1776
- ...new Set(m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code))
3054
+ .../* @__PURE__ */ new Set([
3055
+ ...m.events.flatMap((e) => e.interventions ?? []).map((i) => i.code),
3056
+ ...(m.interventions ?? []).map((i) => i.code)
3057
+ ])
1777
3058
  ];
1778
3059
  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}:`,
3060
+ "Rules for `opportunities` \u2014 what this app's plan is MISSING:",
3061
+ "You are reading this app's real lifecycle anyway. Sweep it deliberately:",
3062
+ "walk each surface the plan should care about \u2014 signup and auth,",
3063
+ "billing/payments (renewals, failures, upgrades, downgrades, cancellation),",
3064
+ "the core engagement loop, support/feedback/refund flows, sharing/invites,",
3065
+ "and any expiry or dormancy mechanics you see \u2014 and for each ask: does a",
3066
+ "churn-relevant moment happen here that the plan does NOT cover?",
1788
3067
  "",
1789
- "{",
1790
- ' "events": [{',
1791
- ' "code": "snake_case_event", "label": "...", "description": "...",',
3068
+ "Entry shapes:",
3069
+ ' events: [{"code": "snake_case_event", "label": "...", "description": "...",',
1792
3070
  ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1793
3071
  ' "expectedEffect": "one sentence: what improves if this is adopted",',
1794
3072
  ' "confidence": 0.0-1.0,',
1795
3073
  ' "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
- "}",
3074
+ ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]}]',
3075
+ ' interventions: [{"code": "snake_case_strategy", "label": "...", "description": "...",',
3076
+ ' "rationale": "...", "confidence": 0.0-1.0, "expectedEffect": "...",',
3077
+ ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]}]',
1805
3078
  "",
1806
- "Rules:",
1807
3079
  `- The plan already covers these events: ${eventCodes.join(", ") || "(none)"}.`,
1808
3080
  ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1809
3081
  " 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.",
3082
+ "- These go straight into the customer's universe, and the events you propose",
3083
+ " get instrumented in this same run. That raises the bar on evidence, not the",
3084
+ " volume: every proposal needs concrete code backing in its rationale, with",
3085
+ " the file/flow named. VERIFY before you write it \u2014 re-open the file you are",
3086
+ " citing and confirm the flow exists as you describe. Drop anything you",
3087
+ " cannot point at real code; a false proposal costs more trust than a missed one.",
3088
+ "- Say where you saw it in `rationale` precisely enough to wire from later \u2014",
3089
+ " it is the only placement note the wiring pass will have for these.",
1814
3090
  "- 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.",
3091
+ " event proposals and 1-3 interventions. Empty is only right when the plan",
3092
+ " genuinely covers the product's lifecycle \u2014 never because you stopped early.",
3093
+ "- Every proposal should include expectedEffect: one sentence on what adoption improves.",
1819
3094
  "- 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."
3095
+ " codes or ones you propose alongside it)."
1822
3096
  ].join("\n");
1823
3097
  }
1824
3098
  function coverageNote(coverage) {
@@ -2140,36 +3414,56 @@ function tokenizeShellSegment(segment) {
2140
3414
 
2141
3415
  // src/core/agent.ts
2142
3416
  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();
3417
+ const {
3418
+ repoPath,
3419
+ config,
3420
+ session,
3421
+ playbook,
3422
+ manifest,
3423
+ progress,
3424
+ onPlanReady,
3425
+ onOpportunitiesReady
3426
+ } = opts;
3427
+ applyModelAuthEnv(config, session);
3428
+ const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
3429
+ const started = Date.now();
2147
3430
  let costUsd = 0;
2148
3431
  const summaries = [];
2149
3432
  let repoMap = "";
2150
3433
  let eventOutcomes = [];
2151
3434
  const phaseTimings = [];
2152
3435
  const BUDGET_FLOOR = 0.25;
3436
+ let planning = { repoMap: "", plan: null, opportunities: emptyOpportunities() };
2153
3437
  if (config.budgetUsd - costUsd > BUDGET_FLOOR) {
2154
3438
  try {
2155
- const map = await runTimedPass("Mapping your codebase", {
2156
- prompt: renderRepoMapPrompt(repoPath),
3439
+ const pass = await runTimedPass("Planning your integration", {
3440
+ prompt: renderPlanningPrompt(repoPath, playbook, manifest),
2157
3441
  systemPrompt: systemPrompt9,
2158
3442
  repoPath,
2159
3443
  model: config.plannerModel,
2160
3444
  effort: "high",
2161
- maxTurns: 40,
2162
- budgetUsd: Math.min(config.budgetUsd - costUsd, config.budgetUsd * 0.15),
3445
+ maxTurns: 60,
3446
+ budgetUsd: Math.min(config.budgetUsd - costUsd, config.budgetUsd * 0.3),
2163
3447
  allowedTools: READ_ONLY_TOOLS,
2164
3448
  progress
2165
3449
  }, phaseTimings);
2166
- costUsd += map.costUsd;
2167
- repoMap = sliceRepoMap(map.summary);
3450
+ costUsd += pass.costUsd;
3451
+ planning = parsePlanningResult(pass.summary);
2168
3452
  } catch (err) {
2169
- debugNote(`repo map pass failed: ${err.message}`);
2170
- repoMap = "";
3453
+ debugNote(`planning pass failed: ${err.message}`);
3454
+ }
3455
+ }
3456
+ repoMap = planning.repoMap;
3457
+ let scopedPlan = null;
3458
+ if (planning.plan) {
3459
+ scopedPlan = planForManifest(planning.plan, manifest);
3460
+ const reviewedPlan = await onPlanReady?.(scopedPlan);
3461
+ if (reviewedPlan !== void 0 && reviewedPlan !== null) {
3462
+ scopedPlan = planForManifest(reviewedPlan, manifest);
2171
3463
  }
2172
3464
  }
3465
+ let appliedOpportunityEvents = [];
3466
+ const opportunitiesProposed = planning.opportunities.events.length + planning.opportunities.interventions.length;
2173
3467
  const corePrompt = [
2174
3468
  `Integrate the Whisperr ${playbook.target.displayName} SDK \u2014 CORE SETUP ONLY.`,
2175
3469
  `Project root: ${repoPath}`,
@@ -2212,6 +3506,13 @@ async function runIntegrationAgent(opts) {
2212
3506
  costUsd += core.costUsd;
2213
3507
  if (core.summary) summaries.push(`Core setup:
2214
3508
  ${core.summary}`);
3509
+ if (onOpportunitiesReady && opportunitiesProposed > 0 && core.ok) {
3510
+ try {
3511
+ appliedOpportunityEvents = await onOpportunitiesReady(planning.opportunities);
3512
+ } catch (err) {
3513
+ debugNote(`universe additions failed: ${err.message}`);
3514
+ }
3515
+ }
2215
3516
  let eventsComplete = true;
2216
3517
  if (manifest.events.length > 0 && config.budgetUsd - costUsd <= BUDGET_FLOOR) {
2217
3518
  eventsComplete = false;
@@ -2224,20 +3525,7 @@ ${core.summary}`);
2224
3525
  "Events: skipped \u2014 the spend limit was reached during core setup. Re-run with a higher WHISPERR_WIZARD_BUDGET_USD to instrument events."
2225
3526
  );
2226
3527
  } 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) {
3528
+ if (!scopedPlan) {
2241
3529
  debugNote("event plan parse failed; falling back to direct event wiring");
2242
3530
  progress?.onActivity?.("Event plan was not parseable; falling back to direct wiring");
2243
3531
  const direct = await runDirectEventsPass({
@@ -2257,21 +3545,23 @@ ${core.summary}`);
2257
3545
  if (direct.pass.summary) summaries.push(`Events:
2258
3546
  ${direct.pass.summary}`);
2259
3547
  } 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
3548
  const placeEntries = scopedPlan.filter((entry) => entry.decision === "place");
2266
3549
  const unsureEntries = scopedPlan.filter((entry) => entry.decision === "unsure");
2267
3550
  let wire;
2268
- if (placeEntries.length && config.budgetUsd - costUsd > BUDGET_FLOOR) {
3551
+ const hasWorkToWire = placeEntries.length > 0 || appliedOpportunityEvents.length > 0;
3552
+ if (hasWorkToWire && config.budgetUsd - costUsd > BUDGET_FLOOR) {
2269
3553
  wire = await runTimedPass("Instrumenting planned events", {
2270
- prompt: renderEventWirePrompt(repoPath, playbook, repoMap, placeEntries),
3554
+ prompt: renderEventWirePrompt(
3555
+ repoPath,
3556
+ playbook,
3557
+ repoMap,
3558
+ placeEntries,
3559
+ appliedOpportunityEvents
3560
+ ),
2271
3561
  systemPrompt: systemPrompt9,
2272
3562
  repoPath,
2273
3563
  model: config.model,
2274
- effort: "high",
3564
+ effort: "medium",
2275
3565
  maxTurns: config.maxTurns,
2276
3566
  budgetUsd: config.budgetUsd - costUsd,
2277
3567
  progress
@@ -2279,7 +3569,7 @@ ${direct.pass.summary}`);
2279
3569
  costUsd += wire.costUsd;
2280
3570
  if (wire.summary) summaries.push(`Events:
2281
3571
  ${wire.summary}`);
2282
- } else if (!placeEntries.length) {
3572
+ } else if (!hasWorkToWire) {
2283
3573
  summaries.push("Events: no events had a confident placement in this surface.");
2284
3574
  }
2285
3575
  let wired = await scanManifestEvents(repoPath, manifest);
@@ -2303,7 +3593,7 @@ ${followUp.summary}`);
2303
3593
  wired = await scanManifestEvents(repoPath, manifest);
2304
3594
  }
2305
3595
  eventOutcomes = buildEventOutcomes(manifest, scopedPlan, wired);
2306
- eventsComplete = !planPass.maxedOut && !(wire?.maxedOut ?? false) && !(followUp?.maxedOut ?? false);
3596
+ eventsComplete = !(wire?.maxedOut ?? false) && !(followUp?.maxedOut ?? false);
2307
3597
  }
2308
3598
  }
2309
3599
  if (core.ok && config.budgetUsd - costUsd > BUDGET_FLOOR) {
@@ -2313,7 +3603,7 @@ ${followUp.summary}`);
2313
3603
  `Project root: ${repoPath}`,
2314
3604
  "",
2315
3605
  ...renderRepoMapSection(repoMap),
2316
- "Run `git diff` and read the surrounding code to see exactly what you added.",
3606
+ "Run `git diff` and read ONLY the code immediately around each change.",
2317
3607
  "For every identify() and track() call, verify \u2014 and FIX in place:",
2318
3608
  "1. Subject \u2014 keys to the END USER, never an admin/staff/operator/seller.",
2319
3609
  " identify() sits on the SAME surface as account_created (same person).",
@@ -2327,21 +3617,19 @@ ${followUp.summary}`);
2327
3617
  " / payment_source / amount / plan). Add the ones you missed.",
2328
3618
  "4. Coverage \u2014 every gateway/callback path that should emit an event does;",
2329
3619
  " 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),
3620
+ "Work from the diff. Do NOT re-explore the repository, do NOT propose new",
3621
+ "events, and change ONLY what is genuinely wrong or incomplete. Be surgical.",
2334
3622
  "",
2335
3623
  "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."
3624
+ "corrections needed.')."
2337
3625
  ].join("\n");
2338
3626
  const review = await runTimedPass("Reviewing & correcting placements", {
2339
3627
  prompt: reviewPrompt,
2340
3628
  systemPrompt: systemPrompt9,
2341
3629
  repoPath,
2342
- model: config.plannerModel,
3630
+ model: config.strictReview ? config.plannerModel : config.model,
2343
3631
  effort: "medium",
2344
- maxTurns: 40,
3632
+ maxTurns: config.strictReview ? 40 : 15,
2345
3633
  budgetUsd: config.budgetUsd - costUsd,
2346
3634
  progress
2347
3635
  }, phaseTimings);
@@ -2364,57 +3652,11 @@ ${review.summary}`);
2364
3652
  coreOk: core.ok,
2365
3653
  eventsComplete,
2366
3654
  eventOutcomes,
2367
- repoMap: repoMap || void 0
3655
+ repoMap: repoMap || void 0,
3656
+ opportunitiesProposed,
3657
+ opportunitiesApplied: appliedOpportunityEvents.length
2368
3658
  };
2369
3659
  }
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
3660
  async function runRepairPass(opts) {
2419
3661
  const {
2420
3662
  repoPath,
@@ -2458,11 +3700,64 @@ async function runRepairPass(opts) {
2458
3700
  }
2459
3701
  var READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
2460
3702
  var FULL_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
3703
+ var PLANNING_JSON_FENCE = /```json\s*([\s\S]*?)```/;
3704
+ function scrubTerminalText(value) {
3705
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
3706
+ }
3707
+ function emptyOpportunities() {
3708
+ return { events: [], interventions: [] };
3709
+ }
3710
+ function parsePlanningResult(text) {
3711
+ const fenced = PLANNING_JSON_FENCE.exec(text);
3712
+ const payload = parseJsonObject(fenced?.[1]) ?? parseJsonObject(sliceLastJsonObject(text));
3713
+ const mapText = fenced ? text.slice(0, fenced.index) : text;
3714
+ const repoMap = sliceRepoMap(stripMapMarkers(mapText));
3715
+ if (!payload) {
3716
+ return { repoMap, plan: parseEventPlan(text), opportunities: emptyOpportunities() };
3717
+ }
3718
+ return {
3719
+ repoMap,
3720
+ plan: Array.isArray(payload.plan) ? coerceEventPlan(payload.plan) : null,
3721
+ opportunities: coerceOpportunities(payload.opportunities)
3722
+ };
3723
+ }
3724
+ function parseJsonObject(raw) {
3725
+ if (!raw?.trim()) return null;
3726
+ try {
3727
+ const parsed = JSON.parse(raw);
3728
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
3729
+ const obj = parsed;
3730
+ if ("plan" in obj || "opportunities" in obj) return obj;
3731
+ }
3732
+ } catch {
3733
+ }
3734
+ return null;
3735
+ }
3736
+ function sliceLastJsonObject(text) {
3737
+ const start = text.lastIndexOf("{\n");
3738
+ const from = start >= 0 ? start : text.indexOf("{");
3739
+ if (from < 0) return null;
3740
+ return sliceBalanced(text, from, "{", "}");
3741
+ }
3742
+ function stripMapMarkers(text) {
3743
+ return text.replace(/^-+\s*REPO MAP\s*-+$/gim, "").replace(/^-+\s*END REPO MAP\s*-+$/gim, "").trim();
3744
+ }
3745
+ function coerceOpportunities(raw) {
3746
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return emptyOpportunities();
3747
+ const doc = raw;
3748
+ return {
3749
+ events: Array.isArray(doc.events) ? doc.events : [],
3750
+ interventions: Array.isArray(doc.interventions) ? doc.interventions : []
3751
+ };
3752
+ }
2461
3753
  function parseEventPlan(text) {
2462
3754
  const parsed = parseFirstJsonArray(text);
2463
3755
  if (!Array.isArray(parsed)) return null;
3756
+ return coerceEventPlan(parsed);
3757
+ }
3758
+ function coerceEventPlan(items) {
2464
3759
  const entries = [];
2465
- for (const item of parsed) {
3760
+ for (const item of items) {
2466
3761
  if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2467
3762
  const obj = item;
2468
3763
  const event = typeof obj.event === "string" ? obj.event.trim() : "";
@@ -2473,7 +3768,10 @@ function parseEventPlan(text) {
2473
3768
  const entry = {
2474
3769
  event,
2475
3770
  decision,
2476
- reason: typeof obj.reason === "string" ? obj.reason.trim() : ""
3771
+ // Model-authored text that ends up in the customer's terminal and in
3772
+ // the run report — scrub it like every other string we take from the
3773
+ // model, so it cannot restyle or spoof the surrounding UI.
3774
+ reason: typeof obj.reason === "string" ? scrubTerminalText(obj.reason) : ""
2477
3775
  };
2478
3776
  if (typeof obj.file === "string" && obj.file.trim()) {
2479
3777
  entry.file = obj.file.trim();
@@ -2491,7 +3789,7 @@ function parseEventPlan(text) {
2491
3789
  function parseFirstJsonArray(text) {
2492
3790
  const start = text.indexOf("[");
2493
3791
  if (start < 0) return null;
2494
- const json = sliceJsonArray(text, start);
3792
+ const json = sliceBalanced(text, start, "[", "]");
2495
3793
  if (!json) return null;
2496
3794
  try {
2497
3795
  return JSON.parse(json);
@@ -2499,7 +3797,7 @@ function parseFirstJsonArray(text) {
2499
3797
  return null;
2500
3798
  }
2501
3799
  }
2502
- function sliceJsonArray(text, start) {
3800
+ function sliceBalanced(text, start, open3, close) {
2503
3801
  let depth = 0;
2504
3802
  let inString = false;
2505
3803
  let escaped = false;
@@ -2517,31 +3815,15 @@ function sliceJsonArray(text, start) {
2517
3815
  }
2518
3816
  if (ch === '"') {
2519
3817
  inString = true;
2520
- } else if (ch === "[") {
3818
+ } else if (ch === open3) {
2521
3819
  depth++;
2522
- } else if (ch === "]") {
3820
+ } else if (ch === close) {
2523
3821
  depth--;
2524
3822
  if (depth === 0) return text.slice(start, i + 1);
2525
3823
  }
2526
3824
  }
2527
3825
  return null;
2528
3826
  }
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
3827
  function sliceRepoMap(map) {
2546
3828
  return map.trim().slice(0, 8e3);
2547
3829
  }
@@ -2550,30 +3832,46 @@ function renderRepoMapSection(repoMap) {
2550
3832
  if (!map) return [];
2551
3833
  return ["----- REPO MAP -----", map, "----- END REPO MAP -----", ""];
2552
3834
  }
2553
- function renderEventPlanPrompt(repoPath, playbook, manifest, repoMap) {
3835
+ function renderPlanningPrompt(repoPath, playbook, manifest) {
2554
3836
  return [
2555
- `Plan Whisperr ${playbook.target.displayName} event placements. This is READ-ONLY.`,
3837
+ `Plan the Whisperr ${playbook.target.displayName} integration for this repo.`,
3838
+ "This pass is READ-ONLY \u2014 do not edit anything. You are deciding what the",
3839
+ "editing passes will do, so be precise about files and anchors.",
2556
3840
  `Project root: ${repoPath}`,
2557
3841
  "",
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"}]',
3842
+ "Produce your answer in TWO parts, in this order.",
2562
3843
  "",
2563
- "Rules:",
3844
+ "PART 1 \u2014 the repo map, as plain text under these exact sections:",
3845
+ "- framework + entry points",
3846
+ "- auth flows enumerated: END-USER vs admin/staff, with precise file paths",
3847
+ "- billing/payment/webhook surfaces",
3848
+ "- analytics/tracking wrapper if any",
3849
+ "- state management",
3850
+ "- directory guide: which dirs hold pages/controllers/services",
3851
+ "List file paths precisely. No prose beyond the map.",
3852
+ "",
3853
+ "PART 2 \u2014 a single ```json fenced block, and nothing after it:",
3854
+ "{",
3855
+ ' "plan": [{"event":"event_name","decision":"place|skip|unsure","file":"path",',
3856
+ ' "anchor":"function/route/handler","properties":["prop"],"reason":"short reason"}],',
3857
+ ' "opportunities": {"events": [], "interventions": []}',
3858
+ "}",
3859
+ "",
3860
+ "Rules for `plan` \u2014 one entry per event in the brief below:",
2564
3861
  "- decision=place only when there is a concrete end-user call site in this repo.",
2565
3862
  "- decision=skip when the event clearly lives on another surface or does not exist here.",
2566
3863
  "- decision=unsure when there is a plausible surface but the exact anchor is not proven.",
2567
3864
  "- For place decisions, include file and anchor precisely.",
2568
3865
  "- properties should list only properties likely available at that anchor.",
2569
- "- No prose beyond the JSON code block.",
3866
+ "",
3867
+ renderOpportunitiesBrief(manifest),
2570
3868
  "",
2571
3869
  "----- EVENTS -----",
2572
3870
  renderEventsBrief(manifest),
2573
3871
  "----- END EVENTS -----"
2574
3872
  ].join("\n");
2575
3873
  }
2576
- function renderEventWirePrompt(repoPath, playbook, repoMap, entries) {
3874
+ function renderEventWirePrompt(repoPath, playbook, repoMap, entries, addedEvents = []) {
2577
3875
  return [
2578
3876
  `The Whisperr ${playbook.target.displayName} SDK is installed and initialized.`,
2579
3877
  "Wire only the planned event placements below with track().",
@@ -2586,9 +3884,39 @@ function renderEventWirePrompt(repoPath, playbook, repoMap, entries) {
2586
3884
  "",
2587
3885
  "----- EVENT WIRING PLAN -----",
2588
3886
  ...renderPlanEntryLines(entries),
2589
- "----- END EVENT WIRING PLAN -----"
3887
+ "----- END EVENT WIRING PLAN -----",
3888
+ // Events the planner proposed and the universe accepted this run. They have
3889
+ // no plan entry (they weren't in the manifest when the plan was written), so
3890
+ // the rationale carries the placement — the planner already saw the site.
3891
+ ...addedEvents.length ? [
3892
+ "",
3893
+ "----- NEWLY ADDED EVENTS -----",
3894
+ "You proposed these while planning and they are now part of this app's",
3895
+ "universe. Wire them the same way \u2014 your own rationale says where you",
3896
+ "saw each one. Skip any that turn out not to have a clear trigger.",
3897
+ ...renderAddedEventLines(addedEvents),
3898
+ "----- END NEWLY ADDED EVENTS -----"
3899
+ ] : []
2590
3900
  ].join("\n");
2591
3901
  }
3902
+ function renderAddedEventLines(events) {
3903
+ const lines = [];
3904
+ for (const event of events) {
3905
+ lines.push("");
3906
+ lines.push(`\u25A0 ${event.code}${event.label ? ` (${event.label})` : ""}`);
3907
+ if (event.description) lines.push(` ${event.description}`);
3908
+ if (event.rationale) lines.push(` where: ${event.rationale}`);
3909
+ if (event.properties?.length) {
3910
+ lines.push(" properties to capture if available:");
3911
+ for (const prop of event.properties) {
3912
+ lines.push(
3913
+ ` \xB7 ${prop.name}${prop.required ? " (required)" : ""}${prop.description ? ` \u2014 ${prop.description}` : ""}`
3914
+ );
3915
+ }
3916
+ }
3917
+ }
3918
+ return lines;
3919
+ }
2592
3920
  function renderEventReconcilePrompt(repoPath, playbook, repoMap, entries) {
2593
3921
  return [
2594
3922
  `One targeted follow-up for Whisperr ${playbook.target.displayName} events.`,
@@ -2929,37 +4257,670 @@ function firstLine(text) {
2929
4257
  const line = text.split("\n").find((l) => l.trim()) ?? text;
2930
4258
  return short(line.trim(), 100);
2931
4259
  }
2932
- function short(s, max = 60) {
2933
- return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
4260
+ function short(s, max = 60) {
4261
+ return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
4262
+ }
4263
+
4264
+ // src/engine/providers/claude.ts
4265
+ var READ_ONLY_TOOLS2 = ["Read", "Glob", "Grep"];
4266
+ var EDIT_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
4267
+ function createClaudeProvider(config, session) {
4268
+ return {
4269
+ name: "claude",
4270
+ async invoke(invocation, roleConfig, onProgress) {
4271
+ applyModelAuthEnv(config, session);
4272
+ const hostTools = invocation.tools.map(
4273
+ (engineTool) => tool(engineTool.name, engineTool.description, engineTool.inputSchema, async (args) => {
4274
+ const result = await engineTool.handler(args);
4275
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
4276
+ })
4277
+ );
4278
+ const allowedTools = [
4279
+ ...invocation.allowRepoWrite ? EDIT_TOOLS : READ_ONLY_TOOLS2,
4280
+ ...invocation.tools.map((engineTool) => `mcp__engine__${engineTool.name}`)
4281
+ ];
4282
+ let sessionId = invocation.resumeSessionId;
4283
+ let costUsd = 0;
4284
+ let turns = 0;
4285
+ const deadline = Date.now() + roleConfig.maxMs;
4286
+ const outcome = (kind, text = "", error = "") => kind === "completed" ? { kind, text, sessionId, costUsd, turns } : kind === "failed" ? { kind, error, sessionId, costUsd, turns } : { kind, sessionId, costUsd, turns };
4287
+ try {
4288
+ const response = query2({
4289
+ prompt: invocation.userPrompt,
4290
+ options: {
4291
+ model: roleConfig.model,
4292
+ cwd: invocation.cwd,
4293
+ systemPrompt: invocation.systemPrompt,
4294
+ thinking: { type: "adaptive" },
4295
+ ...roleConfig.effort ? { effort: roleConfig.effort } : {},
4296
+ tools: [...allowedTools],
4297
+ ...hostTools.length > 0 ? { mcpServers: { engine: createSdkMcpServer({ name: "engine", tools: hostTools }) } } : {},
4298
+ hooks: buildToolPolicyHooks(invocation.cwd),
4299
+ canUseTool: createToolPermissionCallback(invocation.cwd),
4300
+ maxTurns: roleConfig.maxTurns,
4301
+ ...invocation.resumeSessionId ? { resume: invocation.resumeSessionId } : {},
4302
+ settingSources: []
4303
+ }
4304
+ });
4305
+ for await (const raw of response) {
4306
+ if (raw.session_id) {
4307
+ sessionId = raw.session_id;
4308
+ }
4309
+ if (raw.type === "assistant") {
4310
+ turns += 1;
4311
+ const block = raw.message?.content?.find(
4312
+ (candidate) => candidate.type === "text" || candidate.type === "tool_use"
4313
+ );
4314
+ if (block?.type === "text" && block.text?.trim()) {
4315
+ onProgress((block.text.trim().split("\n")[0] ?? "").slice(0, 120));
4316
+ } else if (block?.type === "tool_use" && block.name) {
4317
+ onProgress(`using ${block.name}`);
4318
+ }
4319
+ if (Date.now() > deadline) {
4320
+ await response.interrupt?.();
4321
+ return outcome("budget_exhausted");
4322
+ }
4323
+ } else if (raw.type === "result") {
4324
+ costUsd = raw.total_cost_usd ?? 0;
4325
+ const subtype = raw.subtype ?? "";
4326
+ if (subtype === "success") {
4327
+ return outcome("completed", raw.result ?? "");
4328
+ }
4329
+ if (subtype.includes("max_turns") || subtype.includes("max_budget")) {
4330
+ return outcome("budget_exhausted");
4331
+ }
4332
+ const detail = raw.result ?? subtype;
4333
+ if (classifyFailure(detail) === "context_overflow") {
4334
+ return outcome("context_overflow");
4335
+ }
4336
+ return outcome("failed", "", detail || "model run failed");
4337
+ }
4338
+ }
4339
+ return outcome("failed", "", "model stream ended without a result");
4340
+ } catch (error) {
4341
+ const detail = error instanceof Error ? error.message : String(error);
4342
+ if (classifyFailure(detail) === "context_overflow") {
4343
+ return outcome("context_overflow");
4344
+ }
4345
+ return outcome("failed", "", detail);
4346
+ }
4347
+ }
4348
+ };
4349
+ }
4350
+
4351
+ // src/engine/providers/openai.ts
4352
+ import { readdirSync, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync2 } from "fs";
4353
+ import { join as join8, resolve as resolve2, sep } from "path";
4354
+ function jailedPath(cwd, candidate) {
4355
+ const resolved = resolve2(cwd, candidate);
4356
+ if (resolved !== cwd && !resolved.startsWith(cwd + sep)) {
4357
+ throw new Error(`path ${candidate} escapes the repository`);
4358
+ }
4359
+ return resolved;
4360
+ }
4361
+ function fileTools(cwd, allowWrite) {
4362
+ const tools = [
4363
+ {
4364
+ name: "read_file",
4365
+ description: "Read a repository file (UTF-8, truncated at 40000 chars).",
4366
+ inputSchema: {},
4367
+ jsonSchema: {
4368
+ type: "object",
4369
+ properties: { path: { type: "string" } },
4370
+ required: ["path"]
4371
+ },
4372
+ handler: async (input) => {
4373
+ const content = readFileSync3(jailedPath(cwd, String(input.path ?? "")), "utf8");
4374
+ return { content: content.slice(0, 4e4) };
4375
+ }
4376
+ },
4377
+ {
4378
+ name: "list_dir",
4379
+ description: "List entries of a repository directory.",
4380
+ inputSchema: {},
4381
+ jsonSchema: {
4382
+ type: "object",
4383
+ properties: { path: { type: "string" } },
4384
+ required: ["path"]
4385
+ },
4386
+ handler: async (input) => {
4387
+ const target9 = jailedPath(cwd, String(input.path ?? "."));
4388
+ const entries = readdirSync(target9).map((entry) => {
4389
+ const kind = statSync(join8(target9, entry)).isDirectory() ? "dir" : "file";
4390
+ return `${kind}:${entry}`;
4391
+ });
4392
+ return { entries: entries.slice(0, 500) };
4393
+ }
4394
+ }
4395
+ ];
4396
+ if (allowWrite) {
4397
+ tools.push(
4398
+ {
4399
+ name: "write_file",
4400
+ description: "Create or overwrite a repository file with the given content.",
4401
+ inputSchema: {},
4402
+ jsonSchema: {
4403
+ type: "object",
4404
+ properties: { path: { type: "string" }, content: { type: "string" } },
4405
+ required: ["path", "content"]
4406
+ },
4407
+ handler: async (input) => {
4408
+ writeFileSync2(jailedPath(cwd, String(input.path ?? "")), String(input.content ?? ""));
4409
+ return { ok: true };
4410
+ }
4411
+ },
4412
+ {
4413
+ name: "replace_in_file",
4414
+ description: "Replace one exact occurrence of a string in a repository file.",
4415
+ inputSchema: {},
4416
+ jsonSchema: {
4417
+ type: "object",
4418
+ properties: {
4419
+ path: { type: "string" },
4420
+ oldText: { type: "string" },
4421
+ newText: { type: "string" }
4422
+ },
4423
+ required: ["path", "oldText", "newText"]
4424
+ },
4425
+ handler: async (input) => {
4426
+ const path = jailedPath(cwd, String(input.path ?? ""));
4427
+ const content = readFileSync3(path, "utf8");
4428
+ const oldText = String(input.oldText ?? "");
4429
+ if (!content.includes(oldText)) {
4430
+ return { ok: false, reason: "oldText not found" };
4431
+ }
4432
+ writeFileSync2(path, content.replace(oldText, String(input.newText ?? "")));
4433
+ return { ok: true };
4434
+ }
4435
+ }
4436
+ );
4437
+ }
4438
+ return tools;
4439
+ }
4440
+ function createOpenAIProvider(config, session) {
4441
+ let runId;
4442
+ let conversationId;
4443
+ const gatewayFetch = async (path, body) => {
4444
+ if (!runId) {
4445
+ throw new Error("OpenAI provider used before the run was created");
4446
+ }
4447
+ const response = await fetch(
4448
+ `${config.apiBaseUrl}/wizard/openai/runs/${encodeURIComponent(runId)}${path}`,
4449
+ {
4450
+ method: "POST",
4451
+ headers: {
4452
+ "Content-Type": "application/json",
4453
+ Authorization: `Bearer ${session.token}`
4454
+ },
4455
+ body: JSON.stringify(body)
4456
+ }
4457
+ );
4458
+ const text = await response.text();
4459
+ let payload;
4460
+ try {
4461
+ payload = text ? JSON.parse(text) : {};
4462
+ } catch {
4463
+ payload = {};
4464
+ }
4465
+ if (!response.ok) {
4466
+ const detail = payload.message ?? text;
4467
+ const error = new Error(detail || `gateway request failed (${response.status})`);
4468
+ error.code = payload.code;
4469
+ throw error;
4470
+ }
4471
+ return payload;
4472
+ };
4473
+ const ensureConversation = async (resumeId) => {
4474
+ if (conversationId) {
4475
+ return conversationId;
4476
+ }
4477
+ if (resumeId) {
4478
+ conversationId = resumeId;
4479
+ return resumeId;
4480
+ }
4481
+ try {
4482
+ const created = await gatewayFetch("/v1/conversations", {});
4483
+ conversationId = created.id;
4484
+ } catch (error) {
4485
+ if (error.code !== "conversation_already_bound") {
4486
+ throw error;
4487
+ }
4488
+ }
4489
+ if (!conversationId) {
4490
+ throw new Error("wizard run already has a bound conversation; rerun with its resume state");
4491
+ }
4492
+ return conversationId;
4493
+ };
4494
+ return {
4495
+ name: "openai-gateway",
4496
+ onRunReady(readyRunId) {
4497
+ runId = readyRunId;
4498
+ },
4499
+ async invoke(invocation, roleConfig, onProgress) {
4500
+ let costUsd = 0;
4501
+ let turns = 0;
4502
+ const outcome = (kind, text = "", error = "") => kind === "completed" ? { kind, text, sessionId: conversationId, costUsd, turns } : kind === "failed" ? { kind, error, sessionId: conversationId, costUsd, turns } : { kind, sessionId: conversationId, costUsd, turns };
4503
+ const allTools = [...invocation.tools, ...fileTools(invocation.cwd, invocation.allowRepoWrite)];
4504
+ const toolsByName = new Map(allTools.map((tool2) => [tool2.name, tool2]));
4505
+ const toolDefs = allTools.map((tool2) => ({
4506
+ type: "function",
4507
+ name: tool2.name,
4508
+ description: tool2.description,
4509
+ parameters: tool2.jsonSchema
4510
+ }));
4511
+ try {
4512
+ const conversation = await ensureConversation(invocation.resumeSessionId);
4513
+ const deadline = Date.now() + roleConfig.maxMs;
4514
+ let input = [
4515
+ { role: "user", content: invocation.userPrompt }
4516
+ ];
4517
+ for (let turn = 0; turn < roleConfig.maxTurns; turn += 1) {
4518
+ turns = turn + 1;
4519
+ if (Date.now() > deadline) {
4520
+ return outcome("budget_exhausted");
4521
+ }
4522
+ const response = await gatewayFetch("/v1/responses", {
4523
+ model: roleConfig.model,
4524
+ instructions: invocation.systemPrompt,
4525
+ conversation,
4526
+ input,
4527
+ tools: toolDefs,
4528
+ ...roleConfig.effort ? { reasoning: { effort: roleConfig.effort } } : {}
4529
+ });
4530
+ const items = response.output ?? [];
4531
+ const calls = items.filter(
4532
+ (item) => item.type === "function_call"
4533
+ );
4534
+ if (calls.length === 0) {
4535
+ const text = response.output_text ?? items.filter((item) => item.type === "message").map(
4536
+ (item) => Array.isArray(item.content) ? item.content.map((block) => String(block.text ?? "")).join("") : ""
4537
+ ).join("\n");
4538
+ return outcome("completed", String(text ?? ""));
4539
+ }
4540
+ const results = [];
4541
+ for (const call of calls) {
4542
+ onProgress(`using ${call.name}`);
4543
+ const tool2 = toolsByName.get(call.name);
4544
+ let output;
4545
+ if (!tool2) {
4546
+ output = { ok: false, reason: `unknown tool ${call.name}` };
4547
+ } else {
4548
+ try {
4549
+ output = await tool2.handler(JSON.parse(call.arguments || "{}"));
4550
+ } catch (error) {
4551
+ output = { ok: false, reason: error instanceof Error ? error.message : String(error) };
4552
+ }
4553
+ }
4554
+ results.push({
4555
+ type: "function_call_output",
4556
+ call_id: call.call_id,
4557
+ output: JSON.stringify(output)
4558
+ });
4559
+ }
4560
+ input = results;
4561
+ }
4562
+ return outcome("budget_exhausted");
4563
+ } catch (error) {
4564
+ const detail = error instanceof Error ? error.message : String(error);
4565
+ if (classifyFailure(detail) === "context_overflow") {
4566
+ return outcome("context_overflow");
4567
+ }
4568
+ return outcome("failed", "", detail);
4569
+ }
4570
+ }
4571
+ };
4572
+ }
4573
+
4574
+ // src/engine/providers/router.ts
4575
+ function createRoutedProvider(routes, fallback) {
4576
+ const providers = /* @__PURE__ */ new Set([fallback, ...Object.values(routes)]);
4577
+ return {
4578
+ name: "routed",
4579
+ onRunReady(runId) {
4580
+ for (const provider of providers) {
4581
+ provider.onRunReady?.(runId);
4582
+ }
4583
+ },
4584
+ invoke(invocation, config, onProgress) {
4585
+ const provider = routes[invocation.role] ?? fallback;
4586
+ return provider.invoke(invocation, config, onProgress);
4587
+ }
4588
+ };
4589
+ }
4590
+
4591
+ // src/engine/cli.ts
4592
+ var MINUTE = 6e4;
4593
+ function engineModelConfig(config) {
4594
+ const model = (role, fallback) => process.env[`WHISPERR_WIZARD_ENGINE_${role.toUpperCase()}_MODEL`]?.trim() || fallback;
4595
+ return {
4596
+ survey: { model: model("survey", "claude-sonnet-5"), effort: "medium", maxTurns: 40, maxMs: 12 * MINUTE },
4597
+ design: { model: model("design", config.plannerModel), effort: "high", maxTurns: 40, maxMs: 15 * MINUTE },
4598
+ edit: { model: model("edit", config.model), effort: config.effort, maxTurns: 50, maxMs: 15 * MINUTE },
4599
+ review: { model: model("review", config.model), effort: "medium", maxTurns: 15, maxMs: 8 * MINUTE }
4600
+ };
4601
+ }
4602
+ function engineProvider(config, session) {
4603
+ const claude = createClaudeProvider(config, session);
4604
+ const preset = process.env.WHISPERR_WIZARD_PRESET?.trim() ?? "claude-default";
4605
+ if (preset === "sol-design") {
4606
+ const openai = createOpenAIProvider(config, session);
4607
+ return createRoutedProvider({ survey: openai, design: openai, review: openai }, claude);
4608
+ }
4609
+ return claude;
4610
+ }
4611
+ async function tryEngineFlow(options) {
4612
+ const { repoPath, fingerprint, config, session, playbook, theme: theme2 } = options;
4613
+ const startedAt = Date.now();
4614
+ const sink = createProgressSink({
4615
+ json: false,
4616
+ isTTY: Boolean(process.stdout.isTTY),
4617
+ write: (line) => p.log.message(theme2.muted(line))
4618
+ });
4619
+ const result = await runEngine({
4620
+ repoPath,
4621
+ repoFingerprint: fingerprint,
4622
+ config,
4623
+ session,
4624
+ provider: engineProvider(config, session),
4625
+ models: engineModelConfig(config),
4626
+ target: playbook.target.id,
4627
+ projectKind: ["node", "python", "php", "go", "express", "django", "fastapi", "laravel", "spring-boot"].includes(
4628
+ playbook.target.id
4629
+ ) ? "backend" : "frontend",
4630
+ playbookSystemPrompt: playbook.systemPrompt,
4631
+ sink,
4632
+ callbacks: {
4633
+ reviewEvents: async (proposed) => reviewEventsInTerminal(proposed, theme2),
4634
+ confirmApply: async (patch, summaryLines) => {
4635
+ p.note(summaryLines.join("\n"), "Integration result");
4636
+ const lineCount = patch.split("\n").length;
4637
+ const answer = await p.confirm({
4638
+ message: `Apply these changes to your working tree? (${lineCount} patch lines)`,
4639
+ initialValue: true
4640
+ });
4641
+ return answer === true;
4642
+ }
4643
+ }
4644
+ });
4645
+ if (result.kind === "wrong_mode") {
4646
+ return { handled: false, exitCode: 0 };
4647
+ }
4648
+ if (result.kind === "aborted") {
4649
+ p.cancel(result.reason);
4650
+ return { handled: true, exitCode: 1 };
4651
+ }
4652
+ p.note(result.summary || "Nothing to integrate.", "Run summary");
4653
+ if (result.partialPatchPath) {
4654
+ p.log.warn(`Unapplied changes saved to ${result.partialPatchPath}`);
4655
+ }
4656
+ await postRunReport(config, session, {
4657
+ target: playbook.target.id,
4658
+ repo_fingerprint: fingerprint,
4659
+ identify_wired: result.identifyWired,
4660
+ verified: null,
4661
+ cost_usd: 0,
4662
+ duration_ms: Date.now() - startedAt,
4663
+ summary: result.summary.slice(0, 2e3),
4664
+ events: result.reportEvents,
4665
+ exit_class: result.kind === "completed" ? "engine_completed" : "engine_partial"
4666
+ });
4667
+ if (result.applied && result.registered.length > 0) {
4668
+ const spin = p.spinner();
4669
+ spin.start("Waiting for the first event (deploy or run your app)");
4670
+ const first = await pollFirstEvent(config, session, { timeoutMs: 9e4 });
4671
+ spin.stop(
4672
+ first.received ? theme2.bright(`Whisperr received ${first.eventType ?? "an event"} \u2713`) : theme2.muted("No event yet \u2014 it will show up once the instrumented build runs.")
4673
+ );
4674
+ }
4675
+ return { handled: true, exitCode: result.kind === "completed" ? 0 : 1 };
4676
+ }
4677
+ async function reviewEventsInTerminal(proposed, theme2) {
4678
+ if (proposed.length === 0) {
4679
+ return [];
4680
+ }
4681
+ const lines = proposed.map((event) => {
4682
+ const fields = Object.keys(event.payloadSchema);
4683
+ return [
4684
+ `${theme2.bright(event.code)} \u2014 ${event.name}`,
4685
+ ` ${event.reasoning}`,
4686
+ ` anchor: ${event.anchorFile}${event.anchorSymbol ? ` (${event.anchorSymbol})` : ""}`,
4687
+ fields.length > 0 ? ` payload: ${fields.join(", ")}` : " payload: none"
4688
+ ].join("\n");
4689
+ });
4690
+ p.note(lines.join("\n\n"), `Proposed events (${proposed.length})`);
4691
+ const selection = await p.multiselect({
4692
+ message: "Register these events? Deselect any you don't want.",
4693
+ options: proposed.map((event) => ({ value: event.code, label: event.code, hint: event.name })),
4694
+ initialValues: proposed.map((event) => event.code),
4695
+ required: false
4696
+ });
4697
+ if (p.isCancel(selection)) {
4698
+ return null;
4699
+ }
4700
+ const chosen = new Set(selection);
4701
+ return proposed.filter((event) => chosen.has(event.code));
4702
+ }
4703
+
4704
+ // src/core/opportunities.ts
4705
+ function normalizeCode(value) {
4706
+ return value.trim().toLowerCase().replace(/[ \-./]/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
4707
+ }
4708
+ function sanitizeOpportunities(raw, manifest) {
4709
+ const out = { events: [], interventions: [] };
4710
+ if (typeof raw !== "object" || raw === null) return out;
4711
+ const doc = raw;
4712
+ const knownEvents = new Set(manifest.events.map((e) => normalizeCode(e.eventType)));
4713
+ const knownInterventions = /* @__PURE__ */ new Set([
4714
+ ...manifest.events.flatMap((e) => e.interventions ?? []).map((i) => normalizeCode(i.code)),
4715
+ ...(manifest.interventions ?? []).map((i) => normalizeCode(i.code))
4716
+ ]);
4717
+ const seenEvents = /* @__PURE__ */ new Set();
4718
+ for (const entry of asArray(doc.events)) {
4719
+ const ev = coerceEvent(entry);
4720
+ if (!ev) continue;
4721
+ if (knownEvents.has(ev.code) || seenEvents.has(ev.code)) continue;
4722
+ seenEvents.add(ev.code);
4723
+ out.events.push(ev);
4724
+ }
4725
+ const seenInterventions = /* @__PURE__ */ new Set();
4726
+ for (const entry of asArray(doc.interventions)) {
4727
+ const iv = coerceIntervention(entry);
4728
+ if (!iv) continue;
4729
+ if (knownInterventions.has(iv.code) || seenInterventions.has(iv.code)) continue;
4730
+ seenInterventions.add(iv.code);
4731
+ out.interventions.push(iv);
4732
+ }
4733
+ return out;
4734
+ }
4735
+ var SUBMIT_TIMEOUT_MS = 3e4;
4736
+ async function submitAdditions(config, session, target9, repoFingerprint2, opportunities) {
4737
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/additions`, {
4738
+ method: "POST",
4739
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
4740
+ headers: {
4741
+ "Content-Type": "application/json",
4742
+ Authorization: `Bearer ${session.token}`
4743
+ },
4744
+ body: JSON.stringify({
4745
+ target: target9,
4746
+ repoFingerprint: repoFingerprint2,
4747
+ events: opportunities.events,
4748
+ interventions: opportunities.interventions
4749
+ })
4750
+ });
4751
+ if (!res.ok) {
4752
+ const body = await res.text().catch(() => "");
4753
+ throw new Error(
4754
+ `Submitting universe additions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
4755
+ );
4756
+ }
4757
+ return await res.json();
4758
+ }
4759
+ async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
4760
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
4761
+ method: "POST",
4762
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
4763
+ headers: {
4764
+ "Content-Type": "application/json",
4765
+ Authorization: `Bearer ${session.token}`
4766
+ },
4767
+ body: JSON.stringify({
4768
+ target: target9,
4769
+ repoFingerprint: repoFingerprint2,
4770
+ events: opportunities.events,
4771
+ interventions: opportunities.interventions
4772
+ })
4773
+ });
4774
+ if (res.status === 404) return null;
4775
+ if (!res.ok) {
4776
+ const body = await res.text().catch(() => "");
4777
+ throw new Error(
4778
+ `Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
4779
+ );
4780
+ }
4781
+ return await res.json();
4782
+ }
4783
+ async function fetchSuggestions(config, session, statuses) {
4784
+ if (config.offline) return [];
4785
+ try {
4786
+ const statusQuery = statuses.map(encodeURIComponent).join(",");
4787
+ const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
4788
+ const res = await fetch(url, {
4789
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
4790
+ headers: { Authorization: `Bearer ${session.token}` }
4791
+ });
4792
+ if (!res.ok) return [];
4793
+ const body = await res.json();
4794
+ return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
4795
+ } catch {
4796
+ return [];
4797
+ }
4798
+ }
4799
+ function isSuggestionRecord(value) {
4800
+ if (typeof value !== "object" || value === null) return false;
4801
+ const record = value;
4802
+ 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;
4803
+ }
4804
+ async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
4805
+ if (config.offline) return false;
4806
+ try {
4807
+ const res = await fetch(
4808
+ `${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
4809
+ {
4810
+ method: "POST",
4811
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
4812
+ headers: {
4813
+ "Content-Type": "application/json",
4814
+ Authorization: `Bearer ${session.token}`
4815
+ },
4816
+ body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
4817
+ }
4818
+ );
4819
+ return res.status === 200;
4820
+ } catch {
4821
+ return false;
4822
+ }
4823
+ }
4824
+ function asArray(value) {
4825
+ return Array.isArray(value) ? value : [];
4826
+ }
4827
+ function asString(value) {
4828
+ if (typeof value !== "string") return void 0;
4829
+ const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
4830
+ return cleaned ? cleaned : void 0;
4831
+ }
4832
+ function asConfidence(value) {
4833
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
4834
+ return Math.min(1, Math.max(0, value));
4835
+ }
4836
+ function asWeight(value) {
4837
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
4838
+ if (value < 0 || value > 1) return void 0;
4839
+ return value;
4840
+ }
4841
+ function coerceEvent(entry) {
4842
+ if (typeof entry !== "object" || entry === null) return null;
4843
+ const e = entry;
4844
+ const code = normalizeCode(asString(e.code) ?? "");
4845
+ if (!code) return null;
4846
+ const properties = [];
4847
+ for (const prop of asArray(e.properties)) {
4848
+ if (typeof prop === "string") {
4849
+ if (prop.trim()) properties.push({ name: prop.trim() });
4850
+ continue;
4851
+ }
4852
+ if (typeof prop !== "object" || prop === null) continue;
4853
+ const pr = prop;
4854
+ const name = asString(pr.name);
4855
+ if (!name) continue;
4856
+ const desc = asString(pr.description);
4857
+ properties.push({
4858
+ name,
4859
+ ...desc ? { description: desc } : {},
4860
+ ...pr.required === true ? { required: true } : {}
4861
+ });
4862
+ }
4863
+ const links = [];
4864
+ for (const link of asArray(e.links)) {
4865
+ if (typeof link !== "object" || link === null) continue;
4866
+ const l = link;
4867
+ const interventionCode = normalizeCode(asString(l.interventionCode) ?? "");
4868
+ if (!interventionCode) continue;
4869
+ const weight = asWeight(l.weight);
4870
+ links.push({ interventionCode, ...weight !== void 0 ? { weight } : {} });
4871
+ }
4872
+ const side = normalizeSide(asString(e.side));
4873
+ return {
4874
+ code,
4875
+ label: asString(e.label),
4876
+ description: asString(e.description),
4877
+ ...side ? { side } : {},
4878
+ rationale: asString(e.rationale),
4879
+ expectedEffect: asString(e.expectedEffect),
4880
+ confidence: asConfidence(e.confidence),
4881
+ ...properties.length ? { properties } : {},
4882
+ ...links.length ? { links } : {}
4883
+ };
4884
+ }
4885
+ function coerceIntervention(entry) {
4886
+ if (typeof entry !== "object" || entry === null) return null;
4887
+ const i = entry;
4888
+ const code = normalizeCode(asString(i.code) ?? "");
4889
+ if (!code) return null;
4890
+ const links = [];
4891
+ for (const link of asArray(i.links)) {
4892
+ if (typeof link !== "object" || link === null) continue;
4893
+ const l = link;
4894
+ const eventCode = normalizeCode(asString(l.eventCode) ?? "");
4895
+ if (!eventCode) continue;
4896
+ const weight = asWeight(l.weight);
4897
+ links.push({ eventCode, ...weight !== void 0 ? { weight } : {} });
4898
+ }
4899
+ return {
4900
+ code,
4901
+ label: asString(i.label),
4902
+ description: asString(i.description),
4903
+ rationale: asString(i.rationale),
4904
+ expectedEffect: asString(i.expectedEffect),
4905
+ confidence: asConfidence(i.confidence),
4906
+ ...links.length ? { links } : {}
4907
+ };
2934
4908
  }
2935
-
2936
- // src/core/verify.ts
2937
- async function pollFirstEvent(config, session, opts = {}) {
2938
- if (config.offline) return { received: false };
2939
- const timeoutMs = opts.timeoutMs ?? 12e4;
2940
- const intervalMs = opts.intervalMs ?? 3e3;
2941
- const deadline = Date.now() + timeoutMs;
2942
- while (Date.now() < deadline) {
2943
- if (opts.signal?.aborted) return { received: false };
2944
- try {
2945
- const res = await fetch(`${config.apiBaseUrl}/wizard/first-event`, {
2946
- headers: { authorization: `Bearer ${session.token}` }
2947
- });
2948
- if (res.ok) {
2949
- const body = await res.json();
2950
- if (body.received) {
2951
- return { received: true, eventType: body.event_type };
2952
- }
2953
- }
2954
- } catch {
2955
- }
2956
- await new Promise((r) => setTimeout(r, intervalMs));
4909
+ function normalizeSide(value) {
4910
+ switch (value?.toLowerCase()) {
4911
+ case "frontend":
4912
+ return "frontend";
4913
+ case "backend":
4914
+ return "backend";
4915
+ case "either":
4916
+ return "either";
4917
+ default:
4918
+ return void 0;
2957
4919
  }
2958
- return { received: false };
2959
4920
  }
2960
4921
 
2961
4922
  // src/core/postflight.ts
2962
- import { spawn as spawn2 } from "child_process";
4923
+ import { spawn as spawn4 } from "child_process";
2963
4924
  function verdictToVerified(verdict) {
2964
4925
  if (verdict.toolMissing || verdict.timedOut) return null;
2965
4926
  return verdict.ok;
@@ -2971,8 +4932,8 @@ function runVerifyCommand(repoPath, command, opts = {}) {
2971
4932
  return Promise.resolve({ ran: false, ok: true, output: "" });
2972
4933
  }
2973
4934
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2974
- return new Promise((resolve3) => {
2975
- const child = spawn2(command, { cwd: repoPath, shell: true, env: process.env });
4935
+ return new Promise((resolve4) => {
4936
+ const child = spawn4(command, { cwd: repoPath, shell: true, env: process.env });
2976
4937
  let out = "";
2977
4938
  const append = (d) => {
2978
4939
  out += d.toString();
@@ -2982,11 +4943,11 @@ function runVerifyCommand(repoPath, command, opts = {}) {
2982
4943
  child.stderr?.on("data", append);
2983
4944
  const timer = setTimeout(() => {
2984
4945
  child.kill("SIGKILL");
2985
- resolve3({ ran: true, ok: false, command, output: tail2(out), timedOut: true });
4946
+ resolve4({ ran: true, ok: false, command, output: tail2(out), timedOut: true });
2986
4947
  }, timeoutMs);
2987
4948
  child.on("close", (code) => {
2988
4949
  clearTimeout(timer);
2989
- resolve3({
4950
+ resolve4({
2990
4951
  ran: true,
2991
4952
  ok: code === 0,
2992
4953
  command,
@@ -2998,7 +4959,7 @@ function runVerifyCommand(repoPath, command, opts = {}) {
2998
4959
  });
2999
4960
  child.on("error", () => {
3000
4961
  clearTimeout(timer);
3001
- resolve3({ ran: true, ok: false, command, output: tail2(out), toolMissing: true });
4962
+ resolve4({ ran: true, ok: false, command, output: tail2(out), toolMissing: true });
3002
4963
  });
3003
4964
  });
3004
4965
  }
@@ -3007,51 +4968,24 @@ function tail2(s) {
3007
4968
  return t.length > MAX_OUTPUT ? `\u2026${t.slice(-MAX_OUTPUT)}` : t;
3008
4969
  }
3009
4970
 
3010
- // src/core/report.ts
3011
- function scrubSummary(text) {
3012
- return scrubTerminalControls(text).replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]").replace(/sk-[A-Za-z0-9]{16,}/g, "[redacted]").replace(/AKIA[0-9A-Z]{16}/g, "[redacted]").replace(/Bearer\s+[A-Za-z0-9._-]+/g, "[redacted]").replace(
3013
- /\b(ANTHROPIC_(?:AUTH_TOKEN|API_KEY))\s*=\s*["']?[^"'\s]+["']?/gi,
3014
- "$1=[redacted]"
3015
- );
3016
- }
3017
- async function postRunReport(config, session, report) {
3018
- if (config.offline) return { ok: true };
3019
- try {
3020
- const res = await fetch(`${config.apiBaseUrl}/wizard/report`, {
3021
- method: "POST",
3022
- headers: {
3023
- "Content-Type": "application/json",
3024
- Authorization: `Bearer ${session.token}`
3025
- },
3026
- body: JSON.stringify(report)
3027
- });
3028
- if (res.ok) return { ok: true };
3029
- return {
3030
- ok: false,
3031
- status: res.status,
3032
- detail: await responseDetail(res)
3033
- };
3034
- } catch (err) {
3035
- return { ok: false, detail: detailSlice(errorMessage(err)) };
3036
- }
3037
- }
3038
- async function responseDetail(res) {
3039
- try {
3040
- return detailSlice(await res.text());
3041
- } catch (err) {
3042
- return detailSlice(errorMessage(err));
4971
+ // src/core/version.ts
4972
+ import { readFileSync as readFileSync4 } from "fs";
4973
+ import { dirname as dirname3, join as join9, parse } from "path";
4974
+ import { fileURLToPath } from "url";
4975
+ var PACKAGE_NAME = "@whisperr/wizard";
4976
+ function packageVersion() {
4977
+ let dir = dirname3(fileURLToPath(import.meta.url));
4978
+ const { root } = parse(dir);
4979
+ while (true) {
4980
+ try {
4981
+ const pkg = JSON.parse(readFileSync4(join9(dir, "package.json"), "utf8"));
4982
+ if (pkg.name === PACKAGE_NAME && pkg.version) return pkg.version;
4983
+ } catch {
4984
+ }
4985
+ if (dir === root) return "0.0.0";
4986
+ dir = dirname3(dir);
3043
4987
  }
3044
4988
  }
3045
- function errorMessage(err) {
3046
- return err instanceof Error ? err.message : String(err);
3047
- }
3048
- function detailSlice(detail) {
3049
- const sliced = detail.slice(0, 200).trim();
3050
- return sliced || void 0;
3051
- }
3052
- function scrubTerminalControls(value) {
3053
- return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
3054
- }
3055
4989
 
3056
4990
  // src/core/gapreport.ts
3057
4991
  function buildGapReport(input) {
@@ -3182,12 +5116,12 @@ function banner() {
3182
5116
 
3183
5117
  // src/cli.ts
3184
5118
  async function run2(options) {
3185
- const repoPath = resolve2(options.path ?? process.cwd());
5119
+ const repoPath = resolve3(options.path ?? process.cwd());
3186
5120
  const config = resolveConfig(options);
3187
5121
  console.log(banner());
3188
- p.intro(theme.signal("Let's wire Whisperr into your app."));
5122
+ p2.intro(theme.signal("Let's wire Whisperr into your app."));
3189
5123
  if (config.offline) {
3190
- p.log.warn(
5124
+ p2.log.warn(
3191
5125
  theme.warn("offline mode") + theme.muted(" \u2014 using a demo manifest, no account needed.")
3192
5126
  );
3193
5127
  }
@@ -3197,27 +5131,27 @@ async function run2(options) {
3197
5131
  );
3198
5132
  const chosen = await chooseTarget(detections);
3199
5133
  if (!chosen) {
3200
- p.cancel("No supported stack selected.");
5134
+ p2.cancel("No supported stack selected.");
3201
5135
  return 1;
3202
5136
  }
3203
5137
  if (chosen.playbook.target.availability === "planned") {
3204
- p.note(
5138
+ p2.note(
3205
5139
  `We can detect ${theme.bright(chosen.playbook.target.displayName)} but the Whisperr SDK for it isn't shipped yet.
3206
5140
  Flutter is live today; ${theme.bright(
3207
5141
  chosen.playbook.target.displayName
3208
5142
  )} is next on the roadmap.`,
3209
5143
  theme.warn("SDK coming soon")
3210
5144
  );
3211
- const cont = await p.confirm({
5145
+ const cont = await p2.confirm({
3212
5146
  message: "Continue anyway and let the agent scaffold against the planned SDK?",
3213
5147
  initialValue: false
3214
5148
  });
3215
- if (p.isCancel(cont) || !cont) {
3216
- p.outro(theme.muted("No problem \u2014 come back when the SDK lands."));
5149
+ if (p2.isCancel(cont) || !cont) {
5150
+ p2.outro(theme.muted("No problem \u2014 come back when the SDK lands."));
3217
5151
  return 0;
3218
5152
  }
3219
5153
  } else {
3220
- p.log.success(
5154
+ p2.log.success(
3221
5155
  `Detected ${theme.bright(chosen.playbook.target.displayName)} ` + theme.muted(`(${chosen.detection?.evidence.join(", ") ?? "selected"})`)
3222
5156
  );
3223
5157
  }
@@ -3225,43 +5159,56 @@ Flutter is live today; ${theme.bright(
3225
5159
  try {
3226
5160
  session = config.offline ? await authenticate(config) : await withBrowserAuth(config);
3227
5161
  } catch (err) {
3228
- p.cancel(theme.alert(err.message));
5162
+ p2.cancel(theme.alert(err.message));
3229
5163
  return 1;
3230
5164
  }
3231
5165
  startSessionKeepalive(config, session);
3232
5166
  const fingerprint = await repoFingerprint(repoPath);
5167
+ if (!config.offline) {
5168
+ const engineOutcome = await tryEngineFlow({
5169
+ repoPath,
5170
+ fingerprint,
5171
+ config,
5172
+ session,
5173
+ playbook: chosen.playbook,
5174
+ theme
5175
+ });
5176
+ if (engineOutcome.handled) {
5177
+ return engineOutcome.exitCode;
5178
+ }
5179
+ }
3233
5180
  const manifest = await withSpinner(
3234
5181
  "Loading your onboarding context",
3235
5182
  () => fetchManifest(config, session, chosen.playbook.target.id, fingerprint)
3236
5183
  );
3237
5184
  const approvedSuggestionsPromise = config.offline ? null : fetchSuggestions(config, session, ["approved"]);
3238
- p.note(
5185
+ p2.note(
3239
5186
  summarizeManifest(manifest),
3240
5187
  theme.signal(`Plan for ${manifest.appName ?? manifest.appId}`)
3241
5188
  );
3242
5189
  const checkpoint = await takeCheckpoint(repoPath);
3243
5190
  if (!checkpoint.isRepo) {
3244
5191
  if (!options.force) {
3245
- p.cancel(
5192
+ p2.cancel(
3246
5193
  theme.alert("Not a git repository.") + " The wizard edits your code and relies on git to undo safely.\n" + theme.muted("Run `git init` and commit first, or re-run with --force to proceed without a safety net.")
3247
5194
  );
3248
5195
  return 1;
3249
5196
  }
3250
- p.log.warn(
5197
+ p2.log.warn(
3251
5198
  theme.warn("not a git repo") + theme.muted(" \u2014 proceeding with --force; there is no automatic undo.")
3252
5199
  );
3253
5200
  } else if (!await isWorkingTreeClean(repoPath)) {
3254
5201
  if (!options.force) {
3255
- p.cancel(
5202
+ p2.cancel(
3256
5203
  theme.alert("Your working tree has uncommitted changes.") + " Commit or stash them first so the wizard's edits stay isolated and reversible.\n" + theme.muted("Or re-run with --force to proceed anyway.")
3257
5204
  );
3258
5205
  return 1;
3259
5206
  }
3260
- p.log.warn(
5207
+ p2.log.warn(
3261
5208
  theme.warn("uncommitted changes present") + theme.muted(" \u2014 proceeding with --force; a revert would also drop your own changes.")
3262
5209
  );
3263
5210
  }
3264
- const spin = p.spinner();
5211
+ const spin = p2.spinner();
3265
5212
  const useIntegrationSpinner = Boolean(process.stdout.isTTY);
3266
5213
  let phaseLabel = "Integrating";
3267
5214
  let lastLine = "";
@@ -3277,42 +5224,59 @@ Flutter is live today; ${theme.bright(
3277
5224
  if (useIntegrationSpinner) {
3278
5225
  spin.start(theme.bright(phaseLabel));
3279
5226
  } else {
3280
- p.log.step(theme.bright(phaseLabel));
5227
+ p2.log.step(theme.bright(phaseLabel));
3281
5228
  }
3282
5229
  const tick = useIntegrationSpinner ? setInterval(updateIntegrationMessage, 1e3) : void 0;
5230
+ const additions = { proposed: 0, applied: [] };
5231
+ const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
3283
5232
  const outcome = await runIntegrationAgent({
3284
5233
  repoPath,
3285
5234
  config,
3286
5235
  session,
3287
5236
  playbook: chosen.playbook,
3288
5237
  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);
5238
+ // The plan is always shown — it's the last look at what's about to change
5239
+ // in their code. It only becomes a question under --review-plan; the
5240
+ // default run reads it out and keeps going.
5241
+ async onPlanReady(plan) {
5242
+ if (useIntegrationSpinner) {
5243
+ spin.stop(theme.success("Placement plan ready"));
5244
+ }
5245
+ p2.note(renderPlacementPlan(plan), "Placement plan");
5246
+ const placeEntries = plan.filter((entry) => entry.decision === "place");
5247
+ let selectedEvents = placeEntries.map((entry) => entry.event);
5248
+ if (config.reviewPlan && interactive && placeEntries.length) {
5249
+ const selection = await p2.multiselect({
5250
+ message: "Wire these events?",
5251
+ options: placeEntries.map((entry) => ({
5252
+ value: entry.event,
5253
+ label: entry.event,
5254
+ hint: `${entry.file ?? "unknown file"}@${entry.anchor ?? "unknown anchor"}`
5255
+ })),
5256
+ initialValues: selectedEvents,
5257
+ required: false
5258
+ });
5259
+ selectedEvents = p2.isCancel(selection) ? [] : selection;
3314
5260
  }
3315
- } : {},
5261
+ if (useIntegrationSpinner) {
5262
+ spin.start(theme.bright("Continuing integration"));
5263
+ }
5264
+ return filterEventPlan(plan, selectedEvents);
5265
+ },
5266
+ async onOpportunitiesReady(raw) {
5267
+ if (useIntegrationSpinner) spin.stop(theme.success("Planning done"));
5268
+ const applied = await applyOpportunities({
5269
+ config,
5270
+ session,
5271
+ playbook: chosen.playbook,
5272
+ manifest,
5273
+ fingerprint,
5274
+ raw,
5275
+ additions
5276
+ });
5277
+ if (useIntegrationSpinner) spin.start(theme.bright("Continuing integration"));
5278
+ return applied;
5279
+ },
3316
5280
  progress: {
3317
5281
  onPhase(label) {
3318
5282
  phaseLabel = label;
@@ -3321,7 +5285,7 @@ Flutter is live today; ${theme.bright(
3321
5285
  if (useIntegrationSpinner) {
3322
5286
  updateIntegrationMessage();
3323
5287
  } else {
3324
- p.log.step(theme.bright(label));
5288
+ p2.log.step(theme.bright(label));
3325
5289
  }
3326
5290
  },
3327
5291
  onActivity(line) {
@@ -3332,31 +5296,95 @@ Flutter is live today; ${theme.bright(
3332
5296
  }
3333
5297
  }
3334
5298
  }).catch((err) => {
3335
- p.log.error(err.message);
5299
+ p2.log.error(err.message);
3336
5300
  return null;
3337
5301
  });
3338
5302
  if (tick) clearInterval(tick);
5303
+ let verified = null;
5304
+ let reported = false;
5305
+ const collectReportEvents = async () => {
5306
+ if (!outcome) return [];
5307
+ const eventOutcomeByType = new Map(
5308
+ outcome.eventOutcomes.map((event) => [event.event, event])
5309
+ );
5310
+ const eventTypes = [
5311
+ ...manifest.events.map((e) => e.eventType),
5312
+ ...additions.applied.map((e) => e.code)
5313
+ ];
5314
+ let wired = /* @__PURE__ */ new Map();
5315
+ try {
5316
+ wired = await scanWiredEvents(
5317
+ repoPath,
5318
+ await changedFiles(repoPath, checkpoint),
5319
+ eventTypes
5320
+ );
5321
+ } catch {
5322
+ }
5323
+ return eventTypes.map((eventType) => ({
5324
+ event_type: eventType,
5325
+ status: wired.has(eventType) ? "wired" : "skipped",
5326
+ file: wired.get(eventType),
5327
+ // The agent's own words on why it skipped — the field that answers "why
5328
+ // didn't it wire X" without anyone opening the repo.
5329
+ reason: wired.has(eventType) ? void 0 : eventOutcomeByType.get(eventType)?.reason
5330
+ }));
5331
+ };
5332
+ const reportRun = async (exitClass) => {
5333
+ if (reported) return [];
5334
+ reported = true;
5335
+ const reportEvents2 = await collectReportEvents();
5336
+ const result = await postRunReport(config, session, {
5337
+ target: chosen.playbook.target.id,
5338
+ repo_fingerprint: fingerprint,
5339
+ identify_wired: outcome?.coreOk ?? false,
5340
+ verified,
5341
+ cost_usd: outcome?.costUsd ?? 0,
5342
+ duration_ms: outcome?.durationMs ?? Date.now() - startedAt,
5343
+ summary: scrubSummary(outcome?.summary ?? "").slice(0, 4e3),
5344
+ events: reportEvents2,
5345
+ wizard_version: packageVersion(),
5346
+ planner_model: config.plannerModel,
5347
+ editor_model: config.model,
5348
+ effort: config.effort,
5349
+ exit_class: exitClass,
5350
+ phase_timings: outcome?.phaseTimings ?? [],
5351
+ opportunities_proposed: additions.proposed,
5352
+ opportunities_applied: additions.applied.length
5353
+ });
5354
+ if (!result.ok) {
5355
+ p2.log.warn(
5356
+ theme.warn("Couldn't record this run's coverage on the server") + theme.muted(
5357
+ ` (${formatReportFailure(result)}) \u2014 future runs may re-propose events already wired here.`
5358
+ )
5359
+ );
5360
+ }
5361
+ return reportEvents2;
5362
+ };
3339
5363
  if (!outcome) {
3340
5364
  if (useIntegrationSpinner) {
3341
5365
  spin.stop(theme.alert("Integration stopped"));
3342
5366
  } else {
3343
- p.log.error(theme.alert("Integration stopped"));
5367
+ p2.log.error(theme.alert("Integration stopped"));
3344
5368
  }
3345
- await maybeRevert(repoPath, checkpoint, "The run stopped before finishing.");
5369
+ const reverted = await maybeRevert(
5370
+ repoPath,
5371
+ checkpoint,
5372
+ "The run stopped before finishing."
5373
+ );
5374
+ await reportRun(reverted ? "reverted" : "agent_error");
3346
5375
  return 1;
3347
5376
  }
3348
5377
  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");
3349
5378
  if (useIntegrationSpinner) {
3350
5379
  spin.stop(stopLabel);
3351
5380
  } else if (!outcome.coreOk) {
3352
- p.log.warn(stopLabel);
5381
+ p2.log.warn(stopLabel);
3353
5382
  } else {
3354
- p.log.success(stopLabel);
5383
+ p2.log.success(stopLabel);
3355
5384
  }
3356
- const opportunities = await collectOpportunities(repoPath, manifest, checkpoint);
3357
5385
  let files = await changedFiles(repoPath, checkpoint);
3358
5386
  if (files.length) {
3359
- p.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
5387
+ p2.note(files.map((f) => theme.muted("\u2022 ") + f).join("\n"), "Files changed");
3360
5388
  }
3361
5389
  logAgentSummary(outcome.summary);
3362
5390
  if (!outcome.coreOk) {
@@ -3366,14 +5394,15 @@ Flutter is live today; ${theme.bright(
3366
5394
  "The core setup didn't complete, so these changes may be incomplete."
3367
5395
  );
3368
5396
  if (reverted) {
3369
- p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
5397
+ await reportRun("reverted");
5398
+ p2.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
3370
5399
  return 1;
3371
5400
  }
5401
+ await reportRun("core_failed");
3372
5402
  }
3373
- let verified = null;
3374
5403
  if (chosen.playbook.verifyCommand && files.length) {
3375
5404
  const cmd = chosen.playbook.verifyCommand;
3376
- const vspin = p.spinner();
5405
+ const vspin = p2.spinner();
3377
5406
  vspin.start(`Verifying the integration \u2014 ${theme.muted(cmd)}`);
3378
5407
  let verdict = await runVerifyCommand(repoPath, cmd);
3379
5408
  verified = verdictToVerified(verdict);
@@ -3393,7 +5422,7 @@ Flutter is live today; ${theme.bright(
3393
5422
  vspin.stop(
3394
5423
  theme.warn(`Verification failed \u2014 ${cmd}`) + theme.muted(" \u2014 attempting one repair pass")
3395
5424
  );
3396
- const repairSpin = p.spinner();
5425
+ const repairSpin = p2.spinner();
3397
5426
  repairSpin.start(`Repairing verifier failures \u2014 ${theme.muted(cmd)}`);
3398
5427
  try {
3399
5428
  const repair = await runRepairPass({
@@ -3418,7 +5447,7 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3418
5447
  );
3419
5448
  }
3420
5449
  files = await changedFiles(repoPath, checkpoint);
3421
- const reverifySpin = p.spinner();
5450
+ const reverifySpin = p2.spinner();
3422
5451
  reverifySpin.start(`Re-verifying the integration \u2014 ${theme.muted(cmd)}`);
3423
5452
  verdict = await runVerifyCommand(repoPath, cmd);
3424
5453
  verified = verdictToVerified(verdict);
@@ -3442,7 +5471,7 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3442
5471
  }
3443
5472
  if (!verdict.ok && !verdict.toolMissing && !verdict.timedOut) {
3444
5473
  if (verdict.output) {
3445
- p.note(verdict.output, theme.warn("Verifier output"));
5474
+ p2.note(verdict.output, theme.warn("Verifier output"));
3446
5475
  }
3447
5476
  const reverted = await maybeRevert(
3448
5477
  repoPath,
@@ -3450,94 +5479,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3450
5479
  "The integration didn't pass its build/lint check, so the edits may be broken."
3451
5480
  );
3452
5481
  if (reverted) {
3453
- p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
5482
+ await reportRun("reverted");
5483
+ p2.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
3454
5484
  return 1;
3455
5485
  }
5486
+ await reportRun("verify_failed");
3456
5487
  }
3457
5488
  }
3458
5489
  }
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
- }
5490
+ const reportedEvents = await reportRun(
5491
+ outcome.eventsComplete ? "completed" : "events_incomplete"
5492
+ );
5493
+ const reportEvents = reportedEvents.length ? reportedEvents : await collectReportEvents();
5494
+ const wiredMap = new Map(
5495
+ reportEvents.filter((event) => event.status === "wired").map((event) => [event.event_type, event.file ?? ""])
5496
+ );
3541
5497
  try {
3542
5498
  const approvedSuggestions = await approvedSuggestionsPromise;
3543
5499
  if (approvedSuggestions?.length) {
@@ -3564,7 +5520,7 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3564
5520
  )
3565
5521
  )).filter(Boolean).length;
3566
5522
  if (marked > 0) {
3567
- p.log.info(
5523
+ p2.log.info(
3568
5524
  theme.muted(
3569
5525
  `${marked} approved suggestion${marked === 1 ? "" : "s"} marked integrated.`
3570
5526
  )
@@ -3575,38 +5531,35 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3575
5531
  }
3576
5532
  const eventLines = renderEventOutcomeLines(outcome.eventOutcomes, wiredMap);
3577
5533
  if (eventLines) {
3578
- p.note(eventLines, "Events");
5534
+ p2.note(eventLines, "Events");
3579
5535
  }
3580
- const eventOutcomeByType = new Map(
3581
- outcome.eventOutcomes.map((event) => [event.event, event])
3582
- );
3583
5536
  const gapReport = buildGapReport({
3584
5537
  manifest,
3585
5538
  events: reportEvents.map((event) => ({
3586
5539
  eventType: event.event_type,
3587
5540
  status: event.status,
3588
- reason: eventOutcomeByType.get(event.event_type)?.reason
5541
+ reason: event.reason
3589
5542
  }))
3590
5543
  });
3591
5544
  if (gapReport.hasContent) {
3592
- p.note(
5545
+ p2.note(
3593
5546
  renderGapReport(gapReport, theme),
3594
5547
  theme.signal("What you could be doing")
3595
5548
  );
3596
5549
  }
3597
- const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
5550
+ const eventDenominator = manifest.universeSummary?.wireableHere ?? reportEvents.length;
3598
5551
  const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
3599
5552
  const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
3600
- p.log.info(
5553
+ p2.log.info(
3601
5554
  theme.muted(
3602
5555
  `${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})` : "")
3603
5556
  )
3604
5557
  );
3605
5558
  if (!config.offline) {
3606
- p.log.step(
5559
+ p2.log.step(
3607
5560
  theme.bright("Run your app once") + theme.muted(" and trigger any tracked action \u2014 I'll watch for the first event.")
3608
5561
  );
3609
- const verifySpin = p.spinner();
5562
+ const verifySpin = p2.spinner();
3610
5563
  verifySpin.start("Waiting for the first event from your app");
3611
5564
  const first = await pollFirstEvent(config, session, { timeoutMs: 12e4 });
3612
5565
  if (first.received) {
@@ -3630,31 +5583,27 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3630
5583
  `${theme.signal("3.")} Commit & deploy \u2014 Whisperr starts working on real users.`,
3631
5584
  undo ? theme.muted(`Undo everything: ${undo}`) : ""
3632
5585
  ].filter(Boolean).join("\n");
3633
- p.note(nextSteps, theme.bright("Next"));
3634
- p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
5586
+ p2.note(nextSteps, theme.bright("Next"));
5587
+ p2.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
3635
5588
  return 0;
3636
5589
  }
3637
- var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
3638
- async function offerOpportunities(opts) {
3639
- const { manifest, config, session } = opts;
5590
+ async function applyOpportunities(opts) {
5591
+ const { config, session, playbook, manifest, fingerprint, raw, additions } = opts;
5592
+ const clean = sanitizeOpportunities(raw, manifest);
3640
5593
  const openSuggestions = await fetchSuggestions(config, session, ["proposed", "approved"]);
3641
- const openSuggestionKeys = new Set(
3642
- openSuggestions.map(
3643
- (suggestion) => `${suggestion.kind}:${normalizeCode(suggestion.code)}`
3644
- )
5594
+ const openKeys = new Set(
5595
+ openSuggestions.map((s) => `${s.kind}:${normalizeCode(s.code)}`)
3645
5596
  );
3646
5597
  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)}`)
5598
+ events: clean.events.filter((e) => !openKeys.has(`event:${normalizeCode(e.code)}`)),
5599
+ interventions: clean.interventions.filter(
5600
+ (i) => !openKeys.has(`intervention:${normalizeCode(i.code)}`)
3652
5601
  )
3653
5602
  };
3654
- const total = opportunities.events.length + opportunities.interventions.length;
3655
- if (total === 0) return NO_ADDITIONS;
5603
+ additions.proposed = opportunities.events.length + opportunities.interventions.length;
5604
+ if (additions.proposed === 0) return [];
3656
5605
  const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
3657
- p.note(
5606
+ p2.note(
3658
5607
  [
3659
5608
  ...opportunities.events.map((e) => describe("event", e.code, e.rationale ?? e.description)),
3660
5609
  ...opportunities.interventions.map(
@@ -3664,111 +5613,30 @@ async function offerOpportunities(opts) {
3664
5613
  theme.signal("Opportunities found beyond your onboarding plan")
3665
5614
  );
3666
5615
  if (config.offline) {
3667
- 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;
5616
+ p2.log.info(theme.muted("Offline mode \u2014 proposals shown only, nothing submitted."));
5617
+ return [];
3698
5618
  }
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;
5619
+ if (config.proposeOnly) {
5620
+ await stageOpportunities(config, session, playbook, fingerprint, opportunities);
5621
+ return [];
3756
5622
  }
3757
5623
  let result;
3758
5624
  try {
3759
5625
  result = await withSpinner(
3760
5626
  "Adding to your universe",
3761
- () => submitAdditions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
5627
+ () => submitAdditions(config, session, playbook.target.id, fingerprint, opportunities)
3762
5628
  );
3763
5629
  } catch (err) {
3764
- p.log.warn(
5630
+ p2.log.warn(
3765
5631
  theme.warn("Couldn't add the proposals") + theme.muted(` \u2014 ${err.message}. Your universe is unchanged.`)
3766
5632
  );
3767
- return NO_ADDITIONS;
5633
+ return [];
3768
5634
  }
3769
5635
  const lines = result.outcomes.map((o) => {
3770
5636
  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})`) : "";
5637
+ const detail = o.status === "duplicate" ? theme.muted(
5638
+ ` (already in your universe${o.duplicateOf && o.duplicateOf !== o.code ? ` as ${o.duplicateOf}` : ""})`
5639
+ ) : o.status === "invalid" && o.reason ? theme.muted(` (${o.reason})`) : "";
3772
5640
  return `${mark} ${o.kind} ${theme.bright(o.code)}${detail}`;
3773
5641
  });
3774
5642
  lines.push(
@@ -3776,7 +5644,7 @@ async function offerOpportunities(opts) {
3776
5644
  `${result.applied} added \xB7 ${result.duplicates} already present \xB7 ${result.invalid} rejected`
3777
5645
  )
3778
5646
  );
3779
- if (submitted.interventions.length) {
5647
+ if (opportunities.interventions.length) {
3780
5648
  lines.push(theme.muted("New interventions start paused \u2014 activate them in your dashboard."));
3781
5649
  }
3782
5650
  if (result.policyRegen?.status === "pending") {
@@ -3791,68 +5659,76 @@ async function offerOpportunities(opts) {
3791
5659
  )
3792
5660
  );
3793
5661
  }
3794
- p.note(lines.join("\n"), theme.signal("Universe updated"));
5662
+ p2.note(lines.join("\n"), theme.signal("Universe updated"));
3795
5663
  if (result.policyRegen?.status === "failed") {
3796
- p.log.warn(
5664
+ p2.log.warn(
3797
5665
  theme.warn("Live runtime NOT updated") + theme.muted(
3798
5666
  " \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."
3799
5667
  )
3800
5668
  );
3801
5669
  }
3802
- const appliedEventCodes = new Set(
5670
+ const appliedCodes = new Set(
3803
5671
  result.outcomes.filter((o) => o.kind === "event" && o.status === "applied").map((o) => o.code)
3804
5672
  );
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"));
5673
+ const applied = opportunities.events.filter((e) => appliedCodes.has(e.code));
5674
+ additions.applied = applied;
5675
+ return applied;
5676
+ }
5677
+ async function stageOpportunities(config, session, playbook, fingerprint, opportunities) {
5678
+ let stageResult;
3810
5679
  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
- }
5680
+ stageResult = await withSpinner(
5681
+ "Sending suggestions for approval",
5682
+ () => submitSuggestions(config, session, playbook.target.id, fingerprint, opportunities)
5683
+ );
3830
5684
  } 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.`)
5685
+ p2.log.warn(
5686
+ theme.warn("Couldn't send the proposals") + theme.muted(` \u2014 ${err.message}. Nothing was submitted.`)
5687
+ );
5688
+ return;
5689
+ }
5690
+ if (!stageResult) {
5691
+ p2.log.warn(
5692
+ theme.warn("This Whisperr backend has no approval queue") + theme.muted(" \u2014 nothing was submitted. Re-run without --propose-only to add them directly.")
3833
5693
  );
5694
+ return;
3834
5695
  }
3835
- const edited = !snapshotsEqual(prePass, await snapshotChanges(opts.repoPath, opts.checkpoint));
3836
- return { acceptedEvents, instrumentationSnapshot: edited ? prePass : null };
5696
+ const lines = stageResult.outcomes.map((outcome) => {
5697
+ const mark = outcome.status === "staged" ? theme.success("\u2713") : outcome.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
5698
+ const reason = outcome.reason ?? (outcome.duplicateOf && outcome.duplicateOf !== outcome.code ? `already queued as ${outcome.duplicateOf}` : void 0);
5699
+ return `${mark} ${outcome.kind} ${theme.bright(outcome.code)}${reason ? theme.muted(` (${reason})`) : ""}`;
5700
+ });
5701
+ lines.push(
5702
+ theme.muted(
5703
+ `${stageResult.staged} staged \xB7 ${stageResult.duplicates} already queued \xB7 ${stageResult.invalid} rejected`
5704
+ )
5705
+ );
5706
+ p2.note(lines.join("\n"), theme.signal("Suggestions submitted"));
5707
+ const where = stageResult.approvalsUrl ? `approve at ${theme.bright(stageResult.approvalsUrl)}` : "approve them in your Whisperr dashboard";
5708
+ p2.log.info(
5709
+ theme.muted(
5710
+ `${stageResult.staged} proposal${stageResult.staged === 1 ? "" : "s"} sent \u2014 ${where}. The next wizard run wires whatever you approve.`
5711
+ )
5712
+ );
3837
5713
  }
3838
5714
  async function maybeRevert(repoPath, checkpoint, reason) {
3839
5715
  if (!checkpoint.isRepo) return false;
3840
5716
  const files = await changedFiles(repoPath, checkpoint);
3841
5717
  if (files.length === 0) return false;
3842
- const doRevert = await p.confirm({
5718
+ const doRevert = await p2.confirm({
3843
5719
  message: `${reason} Revert all of the wizard's changes now?`,
3844
5720
  initialValue: true
3845
5721
  });
3846
- if (p.isCancel(doRevert) || !doRevert) {
5722
+ if (p2.isCancel(doRevert) || !doRevert) {
3847
5723
  const hint = revertHint(checkpoint);
3848
- if (hint) p.log.info(theme.muted(`Left in your working tree. Undo anytime: ${hint}`));
5724
+ if (hint) p2.log.info(theme.muted(`Left in your working tree. Undo anytime: ${hint}`));
3849
5725
  return false;
3850
5726
  }
3851
5727
  const ok = await revertToCheckpoint(repoPath, checkpoint);
3852
5728
  if (ok) {
3853
- p.log.success(theme.success("Reverted to your pre-wizard state."));
5729
+ p2.log.success(theme.success("Reverted to your pre-wizard state."));
3854
5730
  } else {
3855
- p.log.warn(
5731
+ p2.log.warn(
3856
5732
  theme.warn("Couldn't revert automatically \u2014 undo manually: ") + (revertHint(checkpoint) ?? "git reset --hard")
3857
5733
  );
3858
5734
  }
@@ -3883,11 +5759,11 @@ async function chooseTarget(detections) {
3883
5759
  hint: pb.target.availability === "available" ? void 0 : "coming soon"
3884
5760
  });
3885
5761
  }
3886
- const answer = await p.select({
5762
+ const answer = await p2.select({
3887
5763
  message: detections.length ? "I found more than one possible stack \u2014 which should I integrate?" : "I couldn't auto-detect your stack. Which are you using?",
3888
5764
  options
3889
5765
  });
3890
- if (p.isCancel(answer)) return null;
5766
+ if (p2.isCancel(answer)) return null;
3891
5767
  const playbook = playbookByTargetId(answer);
3892
5768
  if (!playbook) return null;
3893
5769
  return {
@@ -3896,11 +5772,11 @@ async function chooseTarget(detections) {
3896
5772
  };
3897
5773
  }
3898
5774
  async function withBrowserAuth(config) {
3899
- p.log.step(
5775
+ p2.log.step(
3900
5776
  theme.bright("Authenticate") + theme.muted(" \u2014 opening your browser to approve this device\u2026")
3901
5777
  );
3902
5778
  const auth = await startDeviceAuth(config);
3903
- p.note(
5779
+ p2.note(
3904
5780
  [
3905
5781
  theme.bright(`Your code: ${auth.userCode}`),
3906
5782
  `${theme.muted("Approval URL:")} ${auth.verificationUrl}`,
@@ -3914,7 +5790,7 @@ async function withBrowserAuth(config) {
3914
5790
  await open2(auth.verificationUrlComplete ?? auth.verificationUrl);
3915
5791
  } catch {
3916
5792
  }
3917
- const spin = p.spinner();
5793
+ const spin = p2.spinner();
3918
5794
  spin.start("Waiting for you to approve in the browser");
3919
5795
  try {
3920
5796
  const session = await auth.poll();
@@ -3926,7 +5802,7 @@ async function withBrowserAuth(config) {
3926
5802
  }
3927
5803
  }
3928
5804
  async function withSpinner(label, fn) {
3929
- const spin = p.spinner();
5805
+ const spin = p2.spinner();
3930
5806
  spin.start(label);
3931
5807
  try {
3932
5808
  const result = await fn();
@@ -3989,16 +5865,14 @@ function renderEventOutcomeLines(outcomes, wiredMap) {
3989
5865
  }
3990
5866
  function logAgentSummary(summary) {
3991
5867
  const cleaned = scrubSummary(summary).trim();
3992
- if (cleaned) p.log.message(cleaned);
5868
+ if (cleaned) p2.log.message(cleaned);
3993
5869
  }
3994
5870
  function shortPhaseLabel(phase) {
3995
5871
  switch (phase) {
3996
- case "Mapping your codebase":
3997
- return "map";
5872
+ case "Planning your integration":
5873
+ return "plan";
3998
5874
  case "Installing the SDK & wiring identify()":
3999
5875
  return "core";
4000
- case "Planning event placements":
4001
- return "plan";
4002
5876
  case "Instrumenting planned events":
4003
5877
  case "Instrumenting your events":
4004
5878
  return "wire";
@@ -4031,12 +5905,7 @@ function formatReportFailure(result) {
4031
5905
  }
4032
5906
 
4033
5907
  // 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
- }
5908
+ var modulePath = fileURLToPath2(import.meta.url);
4040
5909
  function parseArgs(argv) {
4041
5910
  const args = [...argv];
4042
5911
  const opts = {};
@@ -4056,6 +5925,15 @@ function parseArgs(argv) {
4056
5925
  case "--force":
4057
5926
  opts.force = true;
4058
5927
  break;
5928
+ case "--review-plan":
5929
+ opts.reviewPlan = true;
5930
+ break;
5931
+ case "--propose-only":
5932
+ opts.proposeOnly = true;
5933
+ break;
5934
+ case "--strict-review":
5935
+ opts.strictReview = true;
5936
+ break;
4059
5937
  case "--api":
4060
5938
  opts.apiBaseUrl = args[++i];
4061
5939
  break;
@@ -4082,6 +5960,10 @@ function printHelp() {
4082
5960
  ` ${theme.bright("Options")}`,
4083
5961
  " --offline Use a demo manifest, no account/browser needed",
4084
5962
  " --force Proceed without a clean git tree (no safe undo)",
5963
+ " --review-plan Confirm the placement plan before anything is wired",
5964
+ " --propose-only Send new events/interventions for dashboard approval",
5965
+ " instead of adding them to your universe now",
5966
+ " --strict-review Review the finished diff with the deeper planner model",
4085
5967
  " --api <url> Override the Whisperr API base URL",
4086
5968
  " --model <id> Override the coding-agent model",
4087
5969
  " -h, --help Show this help",