fullstackgtm 0.44.0 → 0.45.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 (99) hide show
  1. package/CHANGELOG.md +185 -1
  2. package/README.md +19 -7
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +100 -5130
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/salesforce.d.ts +3 -1
  48. package/dist/connectors/salesforce.js +12 -5
  49. package/dist/connectors/signalSources.js +7 -59
  50. package/dist/icp.d.ts +15 -11
  51. package/dist/icp.js +19 -36
  52. package/dist/judge.d.ts +2 -0
  53. package/dist/judge.js +6 -0
  54. package/dist/marketClassify.d.ts +2 -0
  55. package/dist/marketClassify.js +7 -1
  56. package/dist/mcp-bin.js +2 -2
  57. package/dist/mcp.js +259 -166
  58. package/dist/schedule.d.ts +80 -2
  59. package/dist/schedule.js +254 -1
  60. package/dist/spoolFiles.d.ts +44 -0
  61. package/dist/spoolFiles.js +114 -0
  62. package/dist/types.d.ts +11 -0
  63. package/docs/api.md +73 -7
  64. package/docs/signal-spool-format.md +13 -0
  65. package/llms.txt +15 -6
  66. package/package.json +1 -1
  67. package/skills/fullstackgtm/SKILL.md +1 -1
  68. package/src/audit.ts +27 -1
  69. package/src/cli/audit.ts +447 -0
  70. package/src/cli/auth.ts +549 -0
  71. package/src/cli/call.ts +398 -0
  72. package/src/cli/capabilities.ts +215 -0
  73. package/src/cli/draft.ts +101 -0
  74. package/src/cli/enrich.ts +885 -0
  75. package/src/cli/fix.ts +372 -0
  76. package/src/cli/help.ts +664 -0
  77. package/src/cli/icp.ts +265 -0
  78. package/src/cli/init.ts +65 -0
  79. package/src/cli/market.ts +423 -0
  80. package/src/cli/plans.ts +523 -0
  81. package/src/cli/schedule.ts +526 -0
  82. package/src/cli/shared.ts +375 -0
  83. package/src/cli/signals.ts +434 -0
  84. package/src/cli/suggest.ts +151 -0
  85. package/src/cli/tam.ts +435 -0
  86. package/src/cli/ui.ts +426 -0
  87. package/src/cli.ts +99 -5829
  88. package/src/connector.ts +46 -0
  89. package/src/connectors/hubspot.ts +14 -2
  90. package/src/connectors/salesforce.ts +18 -2
  91. package/src/connectors/signalSources.ts +7 -56
  92. package/src/icp.ts +19 -35
  93. package/src/judge.ts +7 -0
  94. package/src/marketClassify.ts +8 -1
  95. package/src/mcp-bin.ts +2 -2
  96. package/src/mcp.ts +130 -57
  97. package/src/schedule.ts +310 -3
  98. package/src/spoolFiles.ts +116 -0
  99. package/src/types.ts +12 -0
package/src/audit.ts CHANGED
@@ -18,6 +18,19 @@ const DEFAULT_POLICY: Omit<GtmPolicy, "today"> = {
18
18
  requireAccountForDeal: true,
19
19
  };
20
20
 
21
+ /**
22
+ * Wall clock for the plan's stdout-bound `createdAt`, honoring the
23
+ * SOURCE_DATE_EPOCH reproducible-build convention (seconds since the Unix
24
+ * epoch). When set, `audit --demo --json` is byte-deterministic across
25
+ * re-runs — golden tests and CI diffs can pin the payload. Unset (normal
26
+ * use) it is the real clock, exactly as before.
27
+ */
28
+ function nowIso(): string {
29
+ const epoch = process.env.SOURCE_DATE_EPOCH;
30
+ if (epoch && /^\d+$/.test(epoch)) return new Date(Number(epoch) * 1000).toISOString();
31
+ return new Date().toISOString();
32
+ }
33
+
21
34
  export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): GtmPolicy {
22
35
  return {
23
36
  ...DEFAULT_POLICY,
@@ -28,20 +41,33 @@ export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): Gt
28
41
  /**
29
42
  * Run every rule over the snapshot and collect the results into a single
30
43
  * dry-run patch plan. Pass custom rules to extend or replace the built-ins.
44
+ * `onRule` reports per-rule progress (presentation only — a throwing callback
45
+ * never fails the audit).
31
46
  */
32
47
  export function auditSnapshot(
33
48
  snapshot: CanonicalGtmSnapshot,
34
49
  policy: GtmPolicy = defaultPolicy(),
35
50
  rules: GtmAuditRule[] = builtinAuditRules,
51
+ onRule?: (ruleId: string, phase: "start" | "done", findings?: number) => void,
36
52
  ): PatchPlan {
37
53
  const context = { snapshot, policy, index: buildSnapshotIndex(snapshot) };
38
54
  const findings: AuditFinding[] = [];
39
55
  const operations: PatchOperation[] = [];
40
56
 
41
57
  for (const rule of rules) {
58
+ try {
59
+ onRule?.(rule.id, "start");
60
+ } catch {
61
+ // progress is presentation-only
62
+ }
42
63
  const result = rule.evaluate(context);
43
64
  findings.push(...result.findings);
44
65
  operations.push(...result.operations);
66
+ try {
67
+ onRule?.(rule.id, "done", result.findings.length);
68
+ } catch {
69
+ // progress is presentation-only
70
+ }
45
71
  }
46
72
 
47
73
 
@@ -52,7 +78,7 @@ export function auditSnapshot(
52
78
  return {
53
79
  id: `patch_plan_${stableHash(`${snapshot.provider}:${snapshot.generatedAt}:${findings.length}:${operations.length}`)}`,
54
80
  title: "GTM hygiene audit patch plan",
55
- createdAt: new Date().toISOString(),
81
+ createdAt: nowIso(),
56
82
  status: operations.length > 0 ? "needs_approval" : "draft",
57
83
  dryRun: true,
58
84
  summary: `${findings.length} findings and ${operations.length} proposed dry-run operations.`,
@@ -0,0 +1,447 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { auditSnapshot, defaultPolicy } from "../audit.ts";
6
+ import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
7
+ import { getCredential, resolveHubspotConnection } from "../credentials.ts";
8
+ import { patchPlanToMarkdown } from "../format.ts";
9
+ import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, type HealthRollup } from "../health.ts";
10
+ import { createFilePlanStore } from "../planStore.ts";
11
+ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "../report.ts";
12
+ import { reportCounts, reportCrm, reportFindings } from "../runReport.ts";
13
+ import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "../types.ts";
14
+ import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.ts";
15
+ import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table, type Paint } from "./ui.ts";
16
+
17
+
18
+ const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
19
+ info: 0,
20
+ warning: 1,
21
+ critical: 2,
22
+ };
23
+
24
+ function failOnThreshold(args: string[]): AuditFindingSeverity | null {
25
+ const value = option(args, "--fail-on");
26
+ if (!value) return null;
27
+ if (value !== "info" && value !== "warning" && value !== "critical") {
28
+ throw new Error("--fail-on must be one of: info, warning, critical");
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export async function snapshotCommand(args: string[]) {
34
+ const since = option(args, "--since");
35
+ let snapshot: CanonicalGtmSnapshot;
36
+ if (since) {
37
+ const provider = option(args, "--provider");
38
+ if (!provider) throw new Error("--since requires --provider <name>");
39
+ const connector = await connectorFor(provider, args);
40
+ if (!connector.fetchChanges) {
41
+ throw new Error(`The ${provider} connector does not support incremental fetch.`);
42
+ }
43
+ snapshot = await connector.fetchChanges(since);
44
+ } else {
45
+ snapshot = await readSnapshot(args);
46
+ }
47
+ const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
48
+ const archive = option(args, "--archive");
49
+ if (archive) {
50
+ const dir = resolve(process.cwd(), archive);
51
+ const { mkdirSync } = await import("node:fs");
52
+ mkdirSync(dir, { recursive: true });
53
+ const fileName = `${snapshot.provider}-${snapshot.generatedAt.replace(/[:.]/g, "-")}.json`;
54
+ const path = resolve(dir, fileName);
55
+ writeFileSync(path, serialized);
56
+ console.log(`Archived snapshot to ${path}`);
57
+ return;
58
+ }
59
+ const out = option(args, "--out");
60
+ if (out) {
61
+ writeFileSync(resolve(process.cwd(), out), serialized);
62
+ console.log(
63
+ `Wrote snapshot (${snapshot.users.length} users, ${snapshot.accounts.length} accounts, ` +
64
+ `${snapshot.contacts.length} contacts, ${snapshot.deals.length} deals, ` +
65
+ `${snapshot.activities.length} activities) to ${out}`,
66
+ );
67
+ } else {
68
+ console.log(serialized.trimEnd());
69
+ }
70
+ }
71
+
72
+ // #2 (dx-punch-list): every read verb points forward. `audit` is the keystone —
73
+ // `audit --demo` is the most-run first command and used to dead-end on a blank
74
+ // line. Context-aware guidance mirrors doctor's "Next step" and fix's chaining,
75
+ // stepping the user along Detect → Govern → apply. Printed to stderr so stdout
76
+ // stays clean for pipes/--out; suppressed under --json (machine/agent context).
77
+ function auditNextStep(args: string[], plan: PatchPlan): string {
78
+ const provider = option(args, "--provider");
79
+ const usingLiveData = Boolean(provider) || Boolean(option(args, "--input"));
80
+ const saved = saveRequested(args);
81
+ const count = plan.findings.length;
82
+
83
+ if (count === 0) {
84
+ return [
85
+ "✓ No findings — this snapshot is clean. Nothing to apply.",
86
+ " Keep it clean: gate new records with `fullstackgtm resolve`,",
87
+ " and schedule a recurring check with `fullstackgtm schedule add ...`.",
88
+ ].join("\n");
89
+ }
90
+
91
+ const head = `${count} finding${count === 1 ? "" : "s"} — a dry-run plan. Nothing was written.`;
92
+ if (!usingLiveData) {
93
+ return [
94
+ head,
95
+ "Next:",
96
+ " • Client-ready writeup: fullstackgtm report --demo",
97
+ " • Run on your CRM: fullstackgtm login hubspot → fullstackgtm audit --provider hubspot --save",
98
+ ].join("\n");
99
+ }
100
+ if (!saved) {
101
+ return [
102
+ head,
103
+ "Next: persist it with --save, then suggest → approve → apply:",
104
+ ` fullstackgtm audit --provider ${provider ?? "<name>"} --save`,
105
+ ].join("\n");
106
+ }
107
+ // --save already confirmed the plan id above; chain the governed spine.
108
+ const providerFlag = provider ? ` --provider ${provider}` : "";
109
+ return [
110
+ head,
111
+ "Next: derive values, approve the safe ones, apply:",
112
+ ` fullstackgtm suggest --plan-id ${plan.id}${providerFlag} --out suggestions.json`,
113
+ ` fullstackgtm plans approve ${plan.id} --values-from suggestions.json`,
114
+ ` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
115
+ ].join("\n");
116
+ }
117
+
118
+ /**
119
+ * Resolve the live HubSpot account's record-URL base (e.g.
120
+ * `https://app-na2.hubspot.com/contacts/<portalId>/record`) and report it on the
121
+ * run so the dashboard can deep-link findings. Best-effort: any failure is
122
+ * swallowed — deep-links are a nicety, never a reason to fail the audit.
123
+ */
124
+ async function reportHubspotDeepLinkBase(): Promise<void> {
125
+ try {
126
+ const connection = await resolveHubspotConnection();
127
+ if (!connection?.accessToken) return;
128
+ const response = await fetch("https://api.hubapi.com/account-info/v3/details", {
129
+ headers: { Authorization: `Bearer ${connection.accessToken}` },
130
+ });
131
+ if (!response.ok) return;
132
+ const info = (await response.json()) as { portalId?: number; uiDomain?: string };
133
+ if (!info.portalId || !info.uiDomain) return;
134
+ reportCrm({
135
+ provider: "hubspot",
136
+ recordUrlBase: `https://${info.uiDomain}/contacts/${info.portalId}/record`,
137
+ });
138
+ } catch {
139
+ // deep-link base is best-effort
140
+ }
141
+ }
142
+
143
+ /**
144
+ * When paired (a broker credential exists), hand the local HubSpot token to the
145
+ * hosted deployment so its Integrations page shows HubSpot connected and the
146
+ * backend can sync on its own. The token lands in the same place a manual
147
+ * in-app connect would (encrypted on the org's integration). Best-effort:
148
+ * never blocks or fails the audit.
149
+ */
150
+ async function registerHubspotWithBroker(): Promise<void> {
151
+ try {
152
+ const broker = getCredential("broker");
153
+ if (!broker?.baseUrl || !broker.accessToken) return; // only when paired
154
+ const connection = await resolveHubspotConnection();
155
+ if (!connection?.accessToken) return;
156
+ const response = await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/integration`, {
157
+ method: "POST",
158
+ headers: {
159
+ Authorization: `Bearer ${broker.accessToken}`,
160
+ "Content-Type": "application/json",
161
+ },
162
+ body: JSON.stringify({ provider: "hubspot", token: connection.accessToken }),
163
+ signal: AbortSignal.timeout(8000),
164
+ });
165
+ if (response.ok) {
166
+ console.error(
167
+ `Registered HubSpot with ${broker.baseUrl} — it now shows on the dashboard's Integrations page.`,
168
+ );
169
+ }
170
+ } catch {
171
+ // registering the connection is best-effort; never affect the audit
172
+ }
173
+ }
174
+
175
+ export async function audit(args: string[]) {
176
+ const threshold = failOnThreshold(args);
177
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
178
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded));
179
+ const snapshot = await readSnapshot(args);
180
+
181
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
182
+ const today = option(args, "--today");
183
+ if (today) policy.today = today;
184
+ const staleDealDays = numericOption(args, "--stale-days");
185
+ if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
186
+
187
+ // Interactive terminals watch the rule registry fill in (○ → spinner → ✓,
188
+ // with per-rule finding counts) on stderr; the board erases itself before
189
+ // the plan renders. Piped/CI/agent runs get an inert no-op.
190
+ const board = createChecklist(rules.map((rule) => ({ id: rule.id, label: rule.id })));
191
+ let plan: PatchPlan;
192
+ try {
193
+ plan = auditSnapshot(
194
+ snapshot,
195
+ policy,
196
+ rules,
197
+ board.active
198
+ ? (ruleId, phase, count) =>
199
+ board.update(
200
+ ruleId,
201
+ phase === "start" ? "running" : "ok",
202
+ phase === "done" ? `${count} finding${count === 1 ? "" : "s"}` : undefined,
203
+ )
204
+ : undefined,
205
+ );
206
+ } finally {
207
+ board.done();
208
+ }
209
+ reportCounts({
210
+ findings: plan.findings.length,
211
+ critical: plan.findings.filter((f) => f.severity === "critical").length,
212
+ warning: plan.findings.filter((f) => f.severity === "warning").length,
213
+ });
214
+ // Row-level detail for the hosted dashboard: IDs + issue type only (no field
215
+ // values). Enrich each finding with its proposed op's field/operation when one
216
+ // is linked, so the dashboard can show "what" and "where" without "the value".
217
+ const opByFinding = new Map<string, { field?: string; operation?: string }>();
218
+ for (const op of plan.operations ?? []) {
219
+ for (const findingId of op.findingIds ?? []) {
220
+ if (!opByFinding.has(findingId)) {
221
+ opByFinding.set(findingId, { field: op.field, operation: op.operation });
222
+ }
223
+ }
224
+ }
225
+ reportFindings(
226
+ plan.findings.map((f) => ({
227
+ objectType: f.objectType,
228
+ objectId: f.objectId,
229
+ severity: f.severity,
230
+ ruleId: f.ruleId,
231
+ ...opByFinding.get(f.id),
232
+ })),
233
+ );
234
+ // When auditing a live HubSpot, emit the account's record-URL base so the
235
+ // hosted dashboard can deep-link each finding to the real CRM record. Best
236
+ // effort and HubSpot-only for now; never blocks or fails the audit.
237
+ if (option(args, "--provider") === "hubspot") {
238
+ await reportHubspotDeepLinkBase();
239
+ await registerHubspotWithBroker();
240
+ }
241
+ const out = option(args, "--out");
242
+ if (out) {
243
+ writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
244
+ }
245
+ if (saveRequested(args)) {
246
+ await createFilePlanStore().save(plan);
247
+ // Engagement workspace: stamp the per-profile health timeline + snapshot so
248
+ // the org accrues a continuous record from the verb people already run.
249
+ // Honor --today (audit is deterministic under it); fall back to wall-clock.
250
+ const health = computeHealth(plan, snapshot, today ?? new Date().toISOString());
251
+ appendHealthEntry(health);
252
+ saveWorkspaceSnapshot(plan.id, snapshot);
253
+ console.error(
254
+ `Saved plan ${plan.id}. Health ${health.score}/100 recorded (\`fullstackgtm health\`). ` +
255
+ `Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`,
256
+ );
257
+ }
258
+ if (args.includes("--json")) {
259
+ console.log(JSON.stringify(plan, null, 2));
260
+ } else {
261
+ // Default to the summary view (rule table + counts); the full per-operation
262
+ // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
263
+ // Interactive terminals get the styled rendering; piped output is unchanged.
264
+ console.log(
265
+ stylizePlanMarkdown(
266
+ patchPlanToMarkdown(plan, { summary: !args.includes("--full") }),
267
+ paint(colorEnabled(process.stdout)),
268
+ ),
269
+ );
270
+ console.error(`\n${auditNextStep(args, plan)}`);
271
+ }
272
+
273
+ if (
274
+ threshold &&
275
+ plan.findings.some((finding) => SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold])
276
+ ) {
277
+ process.exitCode = 2;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Roll up the active profile's health timeline (accrued by `audit --save`):
283
+ * current deterministic score, change since the last audit, and per-rule
284
+ * deltas. Read-only — it only reads `health.jsonl`, never re-audits.
285
+ */
286
+ export function healthCommand(args: string[]) {
287
+ const profile = activeWorkspaceProfile();
288
+ const rollup = summarizeHealth(readHealthTimeline(), profile);
289
+ if (!rollup) {
290
+ if (args.includes("--json")) {
291
+ console.log(JSON.stringify({ profile, auditCount: 0 }, null, 2));
292
+ } else {
293
+ console.log(
294
+ `No audits recorded yet for profile "${profile}".\n` +
295
+ "Start the timeline: `fullstackgtm audit --provider <name> --save`" +
296
+ (profile === "default" ? "" : ` --profile ${profile}`) +
297
+ ".",
298
+ );
299
+ }
300
+ return;
301
+ }
302
+ if (args.includes("--json")) {
303
+ console.log(JSON.stringify(rollup, null, 2));
304
+ return;
305
+ }
306
+ const p = paint(colorEnabled(process.stdout));
307
+ // The markdown rollup is a deliverable — piped/redirected output stays
308
+ // byte-identical to the pre-TUI CLI. Interactive terminals get the styled view.
309
+ console.log(p.enabled ? renderHealthRich(rollup, p) : healthToMarkdown(rollup));
310
+ }
311
+
312
+ /** Interactive-terminal rendering of the health rollup: the markdown's facts, styled. */
313
+ function renderHealthRich(rollup: HealthRollup, p: Paint): string {
314
+ const { current, scoreDelta } = rollup;
315
+ const deltaText =
316
+ scoreDelta === null
317
+ ? p.dim("(first audit — no prior reading)")
318
+ : `${scoreDelta === 0 ? p.dim("·") : scoreDelta > 0 ? p.green("▲") : p.red("▼")} ${
319
+ scoreDelta >= 0 ? p.green(`+${scoreDelta}`) : p.red(`${scoreDelta}`)
320
+ } ${p.dim("since the previous audit")}`;
321
+ const trend =
322
+ rollup.history.length > 1
323
+ ? ` ${p.cyan(sparkline(rollup.history.map((point) => point.score)))}`
324
+ : "";
325
+ const severity = (count: number, label: string, painter: (text: string) => string) =>
326
+ count > 0 ? painter(`${count} ${label}`) : p.dim(`${count} ${label}`);
327
+
328
+ const lines = [
329
+ p.bold(`GTM health — profile ${rollup.profile}`),
330
+ "",
331
+ `Score ${p.bold(scoreColor(current.score, p))}${p.dim("/100")} ${deltaText}${trend}`,
332
+ p.dim(`${rollup.auditCount} audit(s), ${shortDay(rollup.first)} → ${shortDay(rollup.latest)}`),
333
+ `Findings: ${formatCount(current.findings)} (${severity(current.severityCounts.critical, "critical", p.red)}, ${severity(current.severityCounts.warning, "warning", p.yellow)}, ${severity(current.severityCounts.info, "info", p.dim)})`,
334
+ "",
335
+ // Widths are measured on raw cell text, so cells stay unstyled here; the
336
+ // header row is dimmed as a whole line after layout.
337
+ ...table([
338
+ ["", "type", "score", "findings", "records"],
339
+ ...(["account", "contact", "deal"] as const).map((type) => {
340
+ const bucket = current.byObjectType[type];
341
+ return ["", type, `${bucket.score}/100`, formatCount(bucket.findings), formatCount(bucket.records)];
342
+ }),
343
+ ]).map((row, index) => (index === 0 ? p.dim(row) : row)),
344
+ ];
345
+
346
+ if (rollup.ruleDeltas.length > 0) {
347
+ // More findings = worse, so a positive delta paints red. The delta is the
348
+ // LAST column: ANSI codes there never skew the padding of columns before it.
349
+ const deltaCell = (delta: number) =>
350
+ delta === 0 ? p.dim("·") : delta > 0 ? p.red(`+${delta}`) : p.green(`${delta}`);
351
+ lines.push(
352
+ "",
353
+ p.dim("By rule (Δ since previous audit)"),
354
+ ...table(
355
+ rollup.ruleDeltas.map((rule) => ["", rule.ruleId, formatCount(rule.current), deltaCell(rule.delta)]),
356
+ ),
357
+ );
358
+ }
359
+
360
+ return lines.join("\n");
361
+ }
362
+
363
+ /** 2026-06-12T08:00:00Z → "2026-06-12". */
364
+ function shortDay(at: string): string {
365
+ return at.slice(0, 10);
366
+ }
367
+
368
+ /**
369
+ * Render an audit as a client-facing deliverable. Same sources and audit
370
+ * options as `audit`; `--plan` instead renders an existing plan JSON without
371
+ * re-fetching (useful for a plan produced earlier or by another machine).
372
+ */
373
+ export async function reportCommand(args: string[]) {
374
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
375
+ const configuredRules = await resolveConfiguredRules(loaded);
376
+
377
+ let plan: PatchPlan;
378
+ let snapshot: CanonicalGtmSnapshot | undefined;
379
+ const planPath = option(args, "--plan");
380
+ if (planPath) {
381
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
382
+ const input = option(args, "--input");
383
+ if (input) {
384
+ snapshot = JSON.parse(
385
+ readFileSync(resolve(process.cwd(), input), "utf8"),
386
+ ) as CanonicalGtmSnapshot;
387
+ }
388
+ } else {
389
+ snapshot = await readSnapshot(args);
390
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
391
+ const today = option(args, "--today");
392
+ if (today) policy.today = today;
393
+ const staleDealDays = numericOption(args, "--stale-days");
394
+ if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
395
+ plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
396
+ }
397
+
398
+ const reportOptions: ReportOptions = {
399
+ title: option(args, "--title") ?? undefined,
400
+ clientName: option(args, "--client") ?? undefined,
401
+ preparedBy: option(args, "--prepared-by") ?? undefined,
402
+ date: option(args, "--today") ?? undefined,
403
+ maxExamplesPerRule: numericOption(args, "--max-examples"),
404
+ rules: configuredRules,
405
+ snapshot,
406
+ };
407
+
408
+ const out = option(args, "--out");
409
+ const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
410
+ if (format !== "markdown" && format !== "html") {
411
+ throw new Error("--format must be markdown or html");
412
+ }
413
+ const rendered =
414
+ format === "html"
415
+ ? auditReportToHtml(plan, reportOptions)
416
+ : auditReportToMarkdown(plan, reportOptions);
417
+
418
+ if (out) {
419
+ writeFileSync(resolve(process.cwd(), out), rendered);
420
+ console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
421
+ } else {
422
+ console.log(rendered.trimEnd());
423
+ }
424
+ }
425
+
426
+ export async function rulesCommand(args: string[]) {
427
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
428
+ const rules = await resolveConfiguredRules(loaded);
429
+ if (args.includes("--json")) {
430
+ console.log(
431
+ JSON.stringify(
432
+ rules.map(({ id, title, description, category }) => ({
433
+ id,
434
+ title,
435
+ description,
436
+ category: category ?? "uncategorized",
437
+ })),
438
+ null,
439
+ 2,
440
+ ),
441
+ );
442
+ return;
443
+ }
444
+ for (const rule of rules) {
445
+ console.log(`${rule.id} [${rule.category ?? "uncategorized"}]\n ${rule.title}\n ${rule.description}\n`);
446
+ }
447
+ }