@whisperr/wizard 0.3.1 → 0.4.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.
- package/dist/index.js +371 -39
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "url";
|
|
|
8
8
|
// src/cli.ts
|
|
9
9
|
import { resolve as resolve2 } from "path";
|
|
10
10
|
import * as p from "@clack/prompts";
|
|
11
|
+
import open2 from "open";
|
|
11
12
|
|
|
12
13
|
// src/core/config.ts
|
|
13
14
|
var DEFAULT_API_BASE = "https://api.whisperr.net";
|
|
@@ -1016,42 +1017,67 @@ var divider = theme.dim("\u2500".repeat(48));
|
|
|
1016
1017
|
// src/core/auth.ts
|
|
1017
1018
|
async function authenticate(config) {
|
|
1018
1019
|
if (config.offline) return offlineSession();
|
|
1020
|
+
const auth = await startDeviceAuth(config);
|
|
1021
|
+
const url = auth.verificationUrlComplete ?? auth.verificationUrl;
|
|
1022
|
+
try {
|
|
1023
|
+
await open(url);
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
return auth.poll();
|
|
1027
|
+
}
|
|
1028
|
+
async function startDeviceAuth(config) {
|
|
1019
1029
|
const authorize = await fetchJson(
|
|
1020
1030
|
`${config.apiBaseUrl}/wizard/device/authorize`,
|
|
1021
1031
|
{ method: "POST", body: JSON.stringify({ client: "whisperr-wizard" }) }
|
|
1022
1032
|
);
|
|
1023
|
-
const url = authorize.verification_uri_complete ?? authorize.verification_uri;
|
|
1024
1033
|
const intervalMs = (authorize.interval ?? 5) * 1e3;
|
|
1025
1034
|
const deadline = Date.now() + (authorize.expires_in ?? 600) * 1e3;
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1035
|
+
return {
|
|
1036
|
+
verificationUrl: authorize.verification_uri,
|
|
1037
|
+
verificationUrlComplete: authorize.verification_uri_complete,
|
|
1038
|
+
userCode: authorize.user_code,
|
|
1039
|
+
async poll() {
|
|
1040
|
+
let wait = intervalMs;
|
|
1041
|
+
while (Date.now() < deadline) {
|
|
1042
|
+
await sleep(wait);
|
|
1043
|
+
const res = await fetch(
|
|
1044
|
+
`${config.apiBaseUrl}/wizard/device/token`,
|
|
1045
|
+
{
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
headers: { "content-type": "application/json" },
|
|
1048
|
+
body: JSON.stringify({ device_code: authorize.device_code })
|
|
1049
|
+
}
|
|
1050
|
+
);
|
|
1051
|
+
if (res.ok) {
|
|
1052
|
+
const tok = await res.json();
|
|
1053
|
+
return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
|
|
1054
|
+
}
|
|
1055
|
+
if (res.status === 429) {
|
|
1056
|
+
wait += 2e3;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (res.status === 428) continue;
|
|
1060
|
+
throw new Error(
|
|
1061
|
+
`Authorization failed (${res.status}). Re-run the wizard and use the printed link and code to try again.`
|
|
1062
|
+
);
|
|
1039
1063
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
|
|
1044
|
-
}
|
|
1045
|
-
if (res.status === 429) {
|
|
1046
|
-
wait += 2e3;
|
|
1047
|
-
continue;
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
"Authorization timed out. Re-run the wizard and use the printed link and code to try again."
|
|
1066
|
+
);
|
|
1048
1067
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function startSessionKeepalive(config, session, intervalMs = 5 * 6e4) {
|
|
1071
|
+
if (config.offline) return () => {
|
|
1072
|
+
};
|
|
1073
|
+
const timer = setInterval(() => {
|
|
1074
|
+
fetch(`${config.apiBaseUrl}/wizard/first-event`, {
|
|
1075
|
+
headers: { authorization: `Bearer ${session.token}` }
|
|
1076
|
+
}).catch(() => {
|
|
1077
|
+
});
|
|
1078
|
+
}, intervalMs);
|
|
1079
|
+
timer.unref?.();
|
|
1080
|
+
return () => clearInterval(timer);
|
|
1055
1081
|
}
|
|
1056
1082
|
function offlineSession() {
|
|
1057
1083
|
return {
|
|
@@ -1405,6 +1431,71 @@ async function submitAdditions(config, session, target9, repoFingerprint2, oppor
|
|
|
1405
1431
|
}
|
|
1406
1432
|
return await res.json();
|
|
1407
1433
|
}
|
|
1434
|
+
async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
|
|
1435
|
+
const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
|
|
1436
|
+
method: "POST",
|
|
1437
|
+
signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
|
|
1438
|
+
headers: {
|
|
1439
|
+
"Content-Type": "application/json",
|
|
1440
|
+
Authorization: `Bearer ${session.token}`
|
|
1441
|
+
},
|
|
1442
|
+
body: JSON.stringify({
|
|
1443
|
+
target: target9,
|
|
1444
|
+
repoFingerprint: repoFingerprint2,
|
|
1445
|
+
events: opportunities.events,
|
|
1446
|
+
interventions: opportunities.interventions
|
|
1447
|
+
})
|
|
1448
|
+
});
|
|
1449
|
+
if (res.status === 404) return null;
|
|
1450
|
+
if (!res.ok) {
|
|
1451
|
+
const body = await res.text().catch(() => "");
|
|
1452
|
+
throw new Error(
|
|
1453
|
+
`Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
return await res.json();
|
|
1457
|
+
}
|
|
1458
|
+
async function fetchSuggestions(config, session, statuses) {
|
|
1459
|
+
if (config.offline) return [];
|
|
1460
|
+
try {
|
|
1461
|
+
const statusQuery = statuses.map(encodeURIComponent).join(",");
|
|
1462
|
+
const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
|
|
1463
|
+
const res = await fetch(url, {
|
|
1464
|
+
signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
|
|
1465
|
+
headers: { Authorization: `Bearer ${session.token}` }
|
|
1466
|
+
});
|
|
1467
|
+
if (!res.ok) return [];
|
|
1468
|
+
const body = await res.json();
|
|
1469
|
+
return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
|
|
1470
|
+
} catch {
|
|
1471
|
+
return [];
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function isSuggestionRecord(value) {
|
|
1475
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1476
|
+
const record = value;
|
|
1477
|
+
return typeof record.id === "string" && (record.kind === "event" || record.kind === "intervention") && typeof record.code === "string" && (record.status === "proposed" || record.status === "approved" || record.status === "rejected" || record.status === "integrated") && typeof record.payload === "object" && record.payload !== null;
|
|
1478
|
+
}
|
|
1479
|
+
async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
|
|
1480
|
+
if (config.offline) return false;
|
|
1481
|
+
try {
|
|
1482
|
+
const res = await fetch(
|
|
1483
|
+
`${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
|
|
1484
|
+
{
|
|
1485
|
+
method: "POST",
|
|
1486
|
+
signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
|
|
1487
|
+
headers: {
|
|
1488
|
+
"Content-Type": "application/json",
|
|
1489
|
+
Authorization: `Bearer ${session.token}`
|
|
1490
|
+
},
|
|
1491
|
+
body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
|
|
1492
|
+
}
|
|
1493
|
+
);
|
|
1494
|
+
return res.status === 200;
|
|
1495
|
+
} catch {
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1408
1499
|
function asArray(value) {
|
|
1409
1500
|
return Array.isArray(value) ? value : [];
|
|
1410
1501
|
}
|
|
@@ -1460,6 +1551,7 @@ function coerceEvent(entry) {
|
|
|
1460
1551
|
description: asString(e.description),
|
|
1461
1552
|
...side ? { side } : {},
|
|
1462
1553
|
rationale: asString(e.rationale),
|
|
1554
|
+
expectedEffect: asString(e.expectedEffect),
|
|
1463
1555
|
confidence: asConfidence(e.confidence),
|
|
1464
1556
|
...properties.length ? { properties } : {},
|
|
1465
1557
|
...links.length ? { links } : {}
|
|
@@ -1484,6 +1576,7 @@ function coerceIntervention(entry) {
|
|
|
1484
1576
|
label: asString(i.label),
|
|
1485
1577
|
description: asString(i.description),
|
|
1486
1578
|
rationale: asString(i.rationale),
|
|
1579
|
+
expectedEffect: asString(i.expectedEffect),
|
|
1487
1580
|
confidence: asConfidence(i.confidence),
|
|
1488
1581
|
...links.length ? { links } : {}
|
|
1489
1582
|
};
|
|
@@ -1684,16 +1777,20 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
|
|
|
1684
1777
|
];
|
|
1685
1778
|
return [
|
|
1686
1779
|
"UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
|
|
1687
|
-
"While auditing you read this app's real lifecycle.
|
|
1688
|
-
"
|
|
1689
|
-
"
|
|
1690
|
-
"
|
|
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.",
|
|
1691
1787
|
`Write a single JSON file at the repo root named ${opportunitiesFile}:`,
|
|
1692
1788
|
"",
|
|
1693
1789
|
"{",
|
|
1694
1790
|
' "events": [{',
|
|
1695
1791
|
' "code": "snake_case_event", "label": "...", "description": "...",',
|
|
1696
1792
|
' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
|
|
1793
|
+
' "expectedEffect": "one sentence: what improves if this is adopted",',
|
|
1697
1794
|
' "confidence": 0.0-1.0,',
|
|
1698
1795
|
' "properties": [{"name": "...", "description": "...", "required": false}],',
|
|
1699
1796
|
' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
|
|
@@ -1701,6 +1798,7 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
|
|
|
1701
1798
|
' "interventions": [{',
|
|
1702
1799
|
' "code": "snake_case_strategy", "label": "...", "description": "...",',
|
|
1703
1800
|
' "rationale": "...", "confidence": 0.0-1.0,',
|
|
1801
|
+
' "expectedEffect": "one sentence: what improves if this is adopted",',
|
|
1704
1802
|
' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
|
|
1705
1803
|
" }]",
|
|
1706
1804
|
"}",
|
|
@@ -1710,8 +1808,14 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
|
|
|
1710
1808
|
` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
|
|
1711
1809
|
" Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
|
|
1712
1810
|
"- Every proposal needs concrete code evidence in its rationale (file/flow),",
|
|
1713
|
-
" not speculation.
|
|
1714
|
-
"
|
|
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.",
|
|
1814
|
+
"- 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.",
|
|
1715
1819
|
"- Link each proposed event to the intervention(s) it should feed (existing",
|
|
1716
1820
|
" codes or ones you propose in the same file).",
|
|
1717
1821
|
"- This file is metadata for the wizard, not app code \u2014 write it and move on."
|
|
@@ -2949,6 +3053,118 @@ function scrubTerminalControls(value) {
|
|
|
2949
3053
|
return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
|
|
2950
3054
|
}
|
|
2951
3055
|
|
|
3056
|
+
// src/core/gapreport.ts
|
|
3057
|
+
function buildGapReport(input) {
|
|
3058
|
+
const outcomes = new Map(
|
|
3059
|
+
input.events.map((event) => [
|
|
3060
|
+
event.eventType,
|
|
3061
|
+
{
|
|
3062
|
+
status: event.status,
|
|
3063
|
+
reason: trimmedReason(event.reason)
|
|
3064
|
+
}
|
|
3065
|
+
])
|
|
3066
|
+
);
|
|
3067
|
+
const interventions = /* @__PURE__ */ new Map();
|
|
3068
|
+
for (const event of input.manifest.events) {
|
|
3069
|
+
for (const intervention of event.interventions ?? []) {
|
|
3070
|
+
let collected = interventions.get(intervention.code);
|
|
3071
|
+
if (!collected) {
|
|
3072
|
+
collected = {
|
|
3073
|
+
code: intervention.code,
|
|
3074
|
+
label: intervention.label,
|
|
3075
|
+
drivingEvents: [],
|
|
3076
|
+
drivingEventSet: /* @__PURE__ */ new Set()
|
|
3077
|
+
};
|
|
3078
|
+
interventions.set(intervention.code, collected);
|
|
3079
|
+
} else if (!collected.label && intervention.label) {
|
|
3080
|
+
collected.label = intervention.label;
|
|
3081
|
+
}
|
|
3082
|
+
if (!collected.drivingEventSet.has(event.eventType)) {
|
|
3083
|
+
collected.drivingEventSet.add(event.eventType);
|
|
3084
|
+
collected.drivingEvents.push(event.eventType);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
const interventionGaps = [];
|
|
3089
|
+
for (const intervention of interventions.values()) {
|
|
3090
|
+
const plannedEvents = intervention.drivingEvents.filter((code) => outcomes.has(code));
|
|
3091
|
+
const missingEvents = plannedEvents.flatMap((code) => {
|
|
3092
|
+
const outcome = outcomes.get(code);
|
|
3093
|
+
if (outcome?.status !== "skipped") return [];
|
|
3094
|
+
return [{ code, ...outcome.reason ? { reason: outcome.reason } : {} }];
|
|
3095
|
+
});
|
|
3096
|
+
if (missingEvents.length === 0) continue;
|
|
3097
|
+
interventionGaps.push({
|
|
3098
|
+
code: intervention.code,
|
|
3099
|
+
...intervention.label ? { label: intervention.label } : {},
|
|
3100
|
+
blocked: missingEvents.length === plannedEvents.length ? "fully" : "partially",
|
|
3101
|
+
missingEvents
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
interventionGaps.sort((a, b) => {
|
|
3105
|
+
if (a.blocked !== b.blocked) return a.blocked === "fully" ? -1 : 1;
|
|
3106
|
+
return b.missingEvents.length - a.missingEvents.length;
|
|
3107
|
+
});
|
|
3108
|
+
const otherSurface = input.manifest.universeSummary?.otherSurface ?? 0;
|
|
3109
|
+
const derived = input.manifest.universeSummary?.derived ?? 0;
|
|
3110
|
+
return {
|
|
3111
|
+
interventionGaps,
|
|
3112
|
+
otherSurface,
|
|
3113
|
+
derived,
|
|
3114
|
+
hasContent: interventionGaps.length > 0 || otherSurface > 0
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
function renderGapReport(report, theme2) {
|
|
3118
|
+
const lines = [];
|
|
3119
|
+
for (const gap of report.interventionGaps) {
|
|
3120
|
+
const status = gap.blocked === "fully" ? "on hold" : "running below potential";
|
|
3121
|
+
lines.push(`\u25A0 ${theme2.bright(gap.label ?? gap.code)}${theme2.muted(` \u2014 ${status}`)}`);
|
|
3122
|
+
for (const event of gap.missingEvents) {
|
|
3123
|
+
const reason = event.reason ? ` \u2014 ${lowercaseFirst(event.reason)}` : "";
|
|
3124
|
+
lines.push(
|
|
3125
|
+
theme2.muted(" \xB7 ") + theme2.bright(event.code) + theme2.muted(` isn't flowing yet${reason}`)
|
|
3126
|
+
);
|
|
3127
|
+
}
|
|
3128
|
+
lines.push(
|
|
3129
|
+
theme2.muted(
|
|
3130
|
+
gap.blocked === "fully" ? " Ship the missing flow and this play switches on by itself \u2014 the strategy is already built for it." : " It works today, but with only part of its signal \u2014 every event above sharpens it."
|
|
3131
|
+
)
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
if (report.otherSurface > 0) {
|
|
3135
|
+
const suffix = report.otherSurface === 1 ? "" : "s";
|
|
3136
|
+
lines.push(
|
|
3137
|
+
`\u25A0 ${report.otherSurface} more event${suffix} live on another surface (your backend or another app)`
|
|
3138
|
+
);
|
|
3139
|
+
lines.push(
|
|
3140
|
+
theme2.muted(
|
|
3141
|
+
" Run the wizard there and they join the same strategy \u2014 nothing to reconfigure."
|
|
3142
|
+
)
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
3145
|
+
if (report.derived > 0) {
|
|
3146
|
+
const suffix = report.derived === 1 ? "" : "s";
|
|
3147
|
+
lines.push(
|
|
3148
|
+
theme2.muted(
|
|
3149
|
+
`${report.derived} derived signal${suffix} (dormancy, anniversaries, expiries) are computed by Whisperr automatically once your raw events flow \u2014 nothing to wire.`
|
|
3150
|
+
)
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
lines.push(
|
|
3154
|
+
theme2.muted(
|
|
3155
|
+
"All of this is capacity your strategy already includes. Each item you unlock compounds the rest."
|
|
3156
|
+
)
|
|
3157
|
+
);
|
|
3158
|
+
return lines.join("\n");
|
|
3159
|
+
}
|
|
3160
|
+
function trimmedReason(reason) {
|
|
3161
|
+
const trimmed = reason?.trim();
|
|
3162
|
+
return trimmed || void 0;
|
|
3163
|
+
}
|
|
3164
|
+
function lowercaseFirst(value) {
|
|
3165
|
+
return value.slice(0, 1).toLowerCase() + value.slice(1);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
2952
3168
|
// src/ui/banner.ts
|
|
2953
3169
|
function banner() {
|
|
2954
3170
|
const wave = theme.signal("\u223F\u223F\u223F");
|
|
@@ -3012,11 +3228,13 @@ Flutter is live today; ${theme.bright(
|
|
|
3012
3228
|
p.cancel(theme.alert(err.message));
|
|
3013
3229
|
return 1;
|
|
3014
3230
|
}
|
|
3231
|
+
startSessionKeepalive(config, session);
|
|
3015
3232
|
const fingerprint = await repoFingerprint(repoPath);
|
|
3016
3233
|
const manifest = await withSpinner(
|
|
3017
3234
|
"Loading your onboarding context",
|
|
3018
3235
|
() => fetchManifest(config, session, chosen.playbook.target.id, fingerprint)
|
|
3019
3236
|
);
|
|
3237
|
+
const approvedSuggestionsPromise = config.offline ? null : fetchSuggestions(config, session, ["approved"]);
|
|
3020
3238
|
p.note(
|
|
3021
3239
|
summarizeManifest(manifest),
|
|
3022
3240
|
theme.signal(`Plan for ${manifest.appName ?? manifest.appId}`)
|
|
@@ -3320,10 +3538,62 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
|
|
|
3320
3538
|
)
|
|
3321
3539
|
);
|
|
3322
3540
|
}
|
|
3541
|
+
try {
|
|
3542
|
+
const approvedSuggestions = await approvedSuggestionsPromise;
|
|
3543
|
+
if (approvedSuggestions?.length) {
|
|
3544
|
+
const integratedEventCodes = new Set(
|
|
3545
|
+
reportEvents.filter((event) => event.status === "wired").map((event) => normalizeCode(event.event_type))
|
|
3546
|
+
);
|
|
3547
|
+
for (const event of manifest.events) {
|
|
3548
|
+
if (event.coverage?.some((entry) => entry.sameSurface && entry.status === "wired")) {
|
|
3549
|
+
integratedEventCodes.add(normalizeCode(event.eventType));
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
const toMark = approvedSuggestions.filter(
|
|
3553
|
+
(suggestion) => suggestion.status === "approved" && (suggestion.kind === "intervention" || integratedEventCodes.has(normalizeCode(suggestion.code)))
|
|
3554
|
+
);
|
|
3555
|
+
const marked = (await Promise.all(
|
|
3556
|
+
toMark.map(
|
|
3557
|
+
(suggestion) => markSuggestionIntegrated(
|
|
3558
|
+
config,
|
|
3559
|
+
session,
|
|
3560
|
+
suggestion.id,
|
|
3561
|
+
chosen.playbook.target.id,
|
|
3562
|
+
fingerprint
|
|
3563
|
+
)
|
|
3564
|
+
)
|
|
3565
|
+
)).filter(Boolean).length;
|
|
3566
|
+
if (marked > 0) {
|
|
3567
|
+
p.log.info(
|
|
3568
|
+
theme.muted(
|
|
3569
|
+
`${marked} approved suggestion${marked === 1 ? "" : "s"} marked integrated.`
|
|
3570
|
+
)
|
|
3571
|
+
);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
} catch {
|
|
3575
|
+
}
|
|
3323
3576
|
const eventLines = renderEventOutcomeLines(outcome.eventOutcomes, wiredMap);
|
|
3324
3577
|
if (eventLines) {
|
|
3325
3578
|
p.note(eventLines, "Events");
|
|
3326
3579
|
}
|
|
3580
|
+
const eventOutcomeByType = new Map(
|
|
3581
|
+
outcome.eventOutcomes.map((event) => [event.event, event])
|
|
3582
|
+
);
|
|
3583
|
+
const gapReport = buildGapReport({
|
|
3584
|
+
manifest,
|
|
3585
|
+
events: reportEvents.map((event) => ({
|
|
3586
|
+
eventType: event.event_type,
|
|
3587
|
+
status: event.status,
|
|
3588
|
+
reason: eventOutcomeByType.get(event.event_type)?.reason
|
|
3589
|
+
}))
|
|
3590
|
+
});
|
|
3591
|
+
if (gapReport.hasContent) {
|
|
3592
|
+
p.note(
|
|
3593
|
+
renderGapReport(gapReport, theme),
|
|
3594
|
+
theme.signal("What you could be doing")
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3327
3597
|
const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
|
|
3328
3598
|
const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
|
|
3329
3599
|
const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
|
|
@@ -3366,7 +3636,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
|
|
|
3366
3636
|
}
|
|
3367
3637
|
var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
|
|
3368
3638
|
async function offerOpportunities(opts) {
|
|
3369
|
-
const {
|
|
3639
|
+
const { manifest, config, session } = opts;
|
|
3640
|
+
const openSuggestions = await fetchSuggestions(config, session, ["proposed", "approved"]);
|
|
3641
|
+
const openSuggestionKeys = new Set(
|
|
3642
|
+
openSuggestions.map(
|
|
3643
|
+
(suggestion) => `${suggestion.kind}:${normalizeCode(suggestion.code)}`
|
|
3644
|
+
)
|
|
3645
|
+
);
|
|
3646
|
+
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)}`)
|
|
3652
|
+
)
|
|
3653
|
+
};
|
|
3370
3654
|
const total = opportunities.events.length + opportunities.interventions.length;
|
|
3371
3655
|
if (total === 0) return NO_ADDITIONS;
|
|
3372
3656
|
const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
|
|
@@ -3390,7 +3674,7 @@ async function offerOpportunities(opts) {
|
|
|
3390
3674
|
return NO_ADDITIONS;
|
|
3391
3675
|
}
|
|
3392
3676
|
const selection = await p.multiselect({
|
|
3393
|
-
message: "
|
|
3677
|
+
message: "Send these to your dashboard for approval?",
|
|
3394
3678
|
options: [
|
|
3395
3679
|
...opportunities.events.map((e) => ({
|
|
3396
3680
|
value: `e:${e.code}`,
|
|
@@ -3403,8 +3687,8 @@ async function offerOpportunities(opts) {
|
|
|
3403
3687
|
hint: i.description ?? i.rationale
|
|
3404
3688
|
}))
|
|
3405
3689
|
],
|
|
3406
|
-
// Opt-in per item: nothing is pre-selected, so
|
|
3407
|
-
// explicit choice rather than an opt-out the user has to notice and undo.
|
|
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.
|
|
3408
3692
|
initialValues: [],
|
|
3409
3693
|
required: false
|
|
3410
3694
|
});
|
|
@@ -3437,6 +3721,39 @@ async function offerOpportunities(opts) {
|
|
|
3437
3721
|
)
|
|
3438
3722
|
}))
|
|
3439
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;
|
|
3756
|
+
}
|
|
3440
3757
|
let result;
|
|
3441
3758
|
try {
|
|
3442
3759
|
result = await withSpinner(
|
|
@@ -3582,10 +3899,25 @@ async function withBrowserAuth(config) {
|
|
|
3582
3899
|
p.log.step(
|
|
3583
3900
|
theme.bright("Authenticate") + theme.muted(" \u2014 opening your browser to approve this device\u2026")
|
|
3584
3901
|
);
|
|
3902
|
+
const auth = await startDeviceAuth(config);
|
|
3903
|
+
p.note(
|
|
3904
|
+
[
|
|
3905
|
+
theme.bright(`Your code: ${auth.userCode}`),
|
|
3906
|
+
`${theme.muted("Approval URL:")} ${auth.verificationUrl}`,
|
|
3907
|
+
theme.muted(
|
|
3908
|
+
"We tried to open your browser. If it didn't open, visit that link on any device and enter the code."
|
|
3909
|
+
)
|
|
3910
|
+
].join("\n"),
|
|
3911
|
+
theme.signal("Approve this device")
|
|
3912
|
+
);
|
|
3913
|
+
try {
|
|
3914
|
+
await open2(auth.verificationUrlComplete ?? auth.verificationUrl);
|
|
3915
|
+
} catch {
|
|
3916
|
+
}
|
|
3585
3917
|
const spin = p.spinner();
|
|
3586
3918
|
spin.start("Waiting for you to approve in the browser");
|
|
3587
3919
|
try {
|
|
3588
|
-
const session = await
|
|
3920
|
+
const session = await auth.poll();
|
|
3589
3921
|
spin.stop(theme.success("Authenticated \u2713"));
|
|
3590
3922
|
return session;
|
|
3591
3923
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whisperr/wizard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Whisperr Wizard — one command to integrate the Whisperr SDK into your app. Authenticates with your onboarded account and uses an AI coding agent to wire up identify() and your business events automatically.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|