fullstackgtm 0.44.0 → 0.46.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 (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. package/docs/roadmap-to-1.0.md +0 -211
@@ -0,0 +1,524 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync, 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 { applyPatchPlan } from "../connector.ts";
8
+ import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.ts";
9
+ import { createChannelConnector } from "../connectors/outboxChannel.ts";
10
+ import { formatPatchPlanRun, patchPlanToMarkdown } from "../format.ts";
11
+ import { mergeSnapshots } from "../merge.ts";
12
+ import { verifyApprovalDigests } from "../integrity.ts";
13
+ import { buildAuditLog, verifyAuditLog } from "../auditLog.ts";
14
+ import { createFilePlanStore } from "../planStore.ts";
15
+ import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
16
+ import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
17
+ import { progressReporter, reportCounts } from "../runReport.ts";
18
+ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
19
+ import type { ValueSuggestion } from "../suggest.ts";
20
+ import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
21
+ import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
22
+ import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
23
+ import { unknownSubcommandError } from "./suggest.ts";
24
+
25
+
26
+ function parseValueOverrides(args: string[]) {
27
+ const valueOverrides: Record<string, unknown> = {};
28
+ for (const pair of repeatedOption(args, "--value")) {
29
+ const separator = pair.indexOf("=");
30
+ if (separator === -1) throw new Error(`--value must look like <operationId>=<value>`);
31
+ valueOverrides[pair.slice(0, separator)] = pair.slice(separator + 1);
32
+ }
33
+ return valueOverrides;
34
+ }
35
+
36
+ function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
37
+ try {
38
+ const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
39
+ if (!existsSync(path)) return undefined;
40
+ return loadEnrichConfig(path);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function readSuggestionValues(path: string, minConfidence: string, includeCreates: boolean) {
47
+ const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")) as {
48
+ suggestions?: ValueSuggestion[];
49
+ };
50
+ if (!Array.isArray(raw.suggestions)) {
51
+ throw new Error(
52
+ `${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`,
53
+ );
54
+ }
55
+ const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
56
+ const overrides: Record<string, string> = {};
57
+ let skipped = 0;
58
+ for (const s of raw.suggestions) {
59
+ if (!s.suggestedValue) continue;
60
+ if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
61
+ overrides[s.operationId] = s.suggestedValue;
62
+ } else {
63
+ skipped += 1;
64
+ }
65
+ }
66
+ return { overrides, skipped };
67
+ }
68
+
69
+ export async function auditLogCommand(args: string[]) {
70
+ const [sub, ...rest] = args;
71
+ if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
72
+ console.log(`Usage:
73
+ audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
74
+ audit-log verify [--in <path>] re-check an exported log's chain and signature
75
+
76
+ export flattens every apply run across all stored plans (this profile) into a
77
+ tamper-evident chain — each entry carries the prior entry's hash, and the chain
78
+ head is HMAC-signed with this install's key — so a change-management process can
79
+ archive one file and later prove it was not edited. verify recomputes the chain
80
+ and (if the signing key is present) the signature.`);
81
+ return;
82
+ }
83
+
84
+ if (sub === "export") {
85
+ const plans = await createFilePlanStore().list();
86
+ const log = buildAuditLog(plans, new Date().toISOString());
87
+ const payload = `${JSON.stringify(log, null, 2)}\n`;
88
+ const outPath = option(rest, "--out");
89
+ if (outPath) {
90
+ writeFileSync(resolve(process.cwd(), outPath), payload);
91
+ console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
92
+ } else if (rest.includes("--json")) {
93
+ console.log(payload);
94
+ } else {
95
+ console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
96
+ }
97
+ return;
98
+ }
99
+
100
+ // verify
101
+ const inPath = option(rest, "--in");
102
+ if (!inPath) throw new Error("audit-log verify requires --in <exported-log.json>");
103
+ const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8")) as Parameters<typeof verifyAuditLog>[0];
104
+ const result = verifyAuditLog(log);
105
+ if (rest.includes("--json")) {
106
+ console.log(JSON.stringify(result, null, 2));
107
+ } else {
108
+ console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
109
+ }
110
+ if (!result.ok) process.exitCode = 2;
111
+ }
112
+
113
+ export async function apply(args: string[]) {
114
+ // `plans approve <id>` teaches the positional shape, so `apply <id>` is a
115
+ // legible guess — it used to be misdiagnosed as a missing --provider.
116
+ // Apply stays flag-explicit (it is the write step); name the exact
117
+ // corrected command instead of executing the inference.
118
+ const positional = args.find((arg) => !arg.startsWith("-") && !isOptionValue(args, arg));
119
+ if (positional && !option(args, "--plan-id") && !option(args, "--plan")) {
120
+ throw new Error(
121
+ "apply takes the plan as a flag, not a positional. " +
122
+ `Try: fullstackgtm apply --plan-id ${positional} --provider <hubspot|salesforce|stripe>`,
123
+ );
124
+ }
125
+ const provider = option(args, "--provider");
126
+ const channel = option(args, "--channel");
127
+ if (!provider && !channel) {
128
+ throw new Error("apply requires --provider <name> (CRM) or --channel <id> (e.g. outbox)");
129
+ }
130
+ if (provider && channel) {
131
+ throw new Error("apply takes --provider OR --channel, not both — a plan applies to one target.");
132
+ }
133
+
134
+ const planId = option(args, "--plan-id");
135
+ const planPath = option(args, "--plan");
136
+ if (!planId && !planPath) throw new Error("apply requires --plan <path> or --plan-id <id>");
137
+
138
+ let plan: PatchPlan;
139
+ let approvedOperationIds: string[];
140
+ let valueOverrides: Record<string, unknown>;
141
+ const store = planId ? createFilePlanStore() : null;
142
+
143
+ if (planId && store) {
144
+ const stored = await store.get(planId);
145
+ if (!stored) throw new Error(`No stored plan with id ${planId}.`);
146
+ if (stored.status !== "approved") {
147
+ throw new Error(
148
+ `Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
149
+ );
150
+ }
151
+ plan = stored.plan;
152
+ approvedOperationIds = stored.approvedOperationIds;
153
+ // Downgrade guard: an approved plan with no signatures is either pre-0.26
154
+ // (re-approve to gain them) or had its approvalDigests stripped to skip the
155
+ // integrity check. Either way, refuse rather than fall back to trusting the
156
+ // file. (A plan with zero approved operations has nothing to apply anyway.)
157
+ if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
158
+ throw new Error(
159
+ `Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
160
+ "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
161
+ `\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
162
+ );
163
+ }
164
+ // Integrity gate: the plan file is re-read from disk, so verify each approved
165
+ // operation still matches what was signed at approval. Verify against the
166
+ // EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
167
+ // gets written must equal what was signed", so an apply-time --value that
168
+ // changes a value the human did not approve is treated as tamper, not a live
169
+ // override. A mismatch means the plan/overrides were edited after approval —
170
+ // refuse the whole apply rather than write an unapproved value.
171
+ valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
172
+ const verification = verifyApprovalDigests(
173
+ stored.plan.operations,
174
+ stored.approvedOperationIds,
175
+ valueOverrides,
176
+ stored.approvalDigests,
177
+ );
178
+ if (!verification.ok) {
179
+ const detail =
180
+ verification.reason === "no_key"
181
+ ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
182
+ : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
183
+ "If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
184
+ "otherwise the plan was edited after approval — review and re-approve.";
185
+ throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
186
+ }
187
+ } else {
188
+ const approve = option(args, "--approve");
189
+ if (!approve) {
190
+ throw new Error('apply requires --approve <ids|all>; nothing is written without approval');
191
+ }
192
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath!), "utf8")) as PatchPlan;
193
+ approvedOperationIds =
194
+ approve === "all"
195
+ ? plan.operations.map((operation) => operation.id)
196
+ : approve.split(",").map((id) => id.trim()).filter(Boolean);
197
+ valueOverrides = parseValueOverrides(args);
198
+ }
199
+
200
+ // Acquire meter: create_record ops are budgeted. Refuse up-front if the
201
+ // approved creates would exceed the current budget window (the plan was
202
+ // capped when `enrich acquire` ran, but the budget may have been spent down
203
+ // since), then charge the meter for what actually lands.
204
+ const approvedSet = new Set(approvedOperationIds);
205
+ const createOps = plan.operations.filter(
206
+ (op) => op.operation === "create_record" && approvedSet.has(op.id),
207
+ );
208
+ const createSpend = (op: PatchOperation) =>
209
+ ((op.afterValue as CreateRecordPayload | undefined)?.estCostUsd) ?? 0;
210
+ if (createOps.length > 0) {
211
+ const acquireConfig = tryLoadAcquireConfig(args)?.acquire;
212
+ if (acquireConfig?.budget) {
213
+ const now = new Date();
214
+ const head = remaining(loadMeter(now), acquireConfig.budget, 0, now);
215
+ const approvedSpend = createOps.reduce((sum, op) => sum + createSpend(op), 0);
216
+ const refusals: string[] = [];
217
+ if (head.records.day !== null && createOps.length > head.records.day)
218
+ refusals.push(`${createOps.length} creates > ${head.records.day} record(s) left today`);
219
+ if (head.records.month !== null && createOps.length > head.records.month)
220
+ refusals.push(`${createOps.length} creates > ${head.records.month} record(s) left this month`);
221
+ if (head.spendUsd.day !== null && approvedSpend > head.spendUsd.day)
222
+ refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.day.toFixed(2)} spend left today`);
223
+ if (head.spendUsd.month !== null && approvedSpend > head.spendUsd.month)
224
+ refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.month.toFixed(2)} spend left this month`);
225
+ if (refusals.length > 0) {
226
+ throw new Error(
227
+ `Refusing to apply: acquire budget exceeded (${refusals.join("; ")}). ` +
228
+ "Re-run `fullstackgtm enrich acquire` to re-cap to the remaining budget, or wait for the window to reset.",
229
+ );
230
+ }
231
+ }
232
+ }
233
+
234
+ // A channel (e.g. outbox) renders approved ops to a local artifact and
235
+ // transmits nothing; a CRM provider writes records. Same governed apply path.
236
+ const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
237
+ // Interactive terminals get a live apply board on stderr while the run
238
+ // executes (preflight → operations → results, with a per-op safety ticker);
239
+ // piped runs render nothing. Either way the emitter streams heartbeats to
240
+ // the paired hosted app when a long run is in flight.
241
+ const renderer = createProgressRenderer(APPLY_STAGES);
242
+ const progress = createProgressEmitter(
243
+ composeListeners(renderer.listener, progressReporter()),
244
+ );
245
+ let run: PatchPlanRun;
246
+ try {
247
+ run = await applyPatchPlan(connector, plan, {
248
+ approvedOperationIds,
249
+ valueOverrides,
250
+ progress,
251
+ });
252
+ } finally {
253
+ renderer.done();
254
+ }
255
+ if (planId && store) {
256
+ await store.recordRun(planId, run);
257
+ }
258
+
259
+ // Charge the acquire meter for the creates that actually landed.
260
+ if (createOps.length > 0) {
261
+ const appliedIds = new Set(
262
+ run.results.filter((result) => result.status === "applied").map((result) => result.operationId),
263
+ );
264
+ const landed = createOps.filter((op) => appliedIds.has(op.id));
265
+ if (landed.length > 0) {
266
+ const spend = landed.reduce((sum, op) => sum + createSpend(op), 0);
267
+ recordConsumption(new Date(), landed.length, spend);
268
+ }
269
+ }
270
+
271
+ // Observability: apply-outcome tallies for the web run timeline.
272
+ const applyTally: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
273
+ for (const result of run.results) applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
274
+ reportCounts(applyTally);
275
+
276
+ // Rich-only closing line on stderr (renderer.active implies an interactive,
277
+ // color-safe stderr): the one-glance outcome of the run.
278
+ if (renderer.active) {
279
+ const pe = paint(true);
280
+ const badge =
281
+ run.status === "applied"
282
+ ? pe.green(`✓ ${applyTally.applied} operation(s) applied`)
283
+ : run.status === "partial"
284
+ ? pe.yellow(`! partial: ${applyTally.applied} applied, ${applyTally.conflict} conflict(s), ${applyTally.failed} failed`)
285
+ : pe.red(`✗ ${run.status}: ${applyTally.applied} applied, ${applyTally.failed} failed`);
286
+ process.stderr.write(`${badge}${planId && store ? pe.dim(" — run recorded on the plan") : ""}\n`);
287
+ }
288
+
289
+ if (args.includes("--json")) {
290
+ console.log(JSON.stringify(run, null, 2));
291
+ } else {
292
+ console.log(formatPatchPlanRun(run));
293
+ }
294
+ if (run.status === "failed") process.exitCode = 1;
295
+ }
296
+
297
+ export async function diffCommand(args: string[]) {
298
+ const beforePath = option(args, "--before");
299
+ const afterPath = option(args, "--after");
300
+ if (!beforePath || !afterPath) {
301
+ throw new Error("diff requires --before <snapshot.json> and --after <snapshot.json>");
302
+ }
303
+ const before = JSON.parse(
304
+ readFileSync(resolve(process.cwd(), beforePath), "utf8"),
305
+ ) as CanonicalGtmSnapshot;
306
+ const after = JSON.parse(
307
+ readFileSync(resolve(process.cwd(), afterPath), "utf8"),
308
+ ) as CanonicalGtmSnapshot;
309
+
310
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
311
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded));
312
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
313
+ const today = option(args, "--today");
314
+ if (today) policy.today = today;
315
+ const staleDealDays = numericOption(args, "--stale-days");
316
+ if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
317
+
318
+ const diff = diffSnapshots(before, after);
319
+ const drift = diffFindings(
320
+ auditSnapshot(before, policy, rules),
321
+ auditSnapshot(after, policy, rules),
322
+ );
323
+
324
+ if (args.includes("--json")) {
325
+ console.log(JSON.stringify({ diff, drift }, null, 2));
326
+ } else {
327
+ console.log(diffToMarkdown(diff, drift));
328
+ }
329
+ if (args.includes("--fail-on-new-findings") && drift.newFindings.length > 0) {
330
+ process.exitCode = 2;
331
+ }
332
+ }
333
+
334
+ export async function mergeCommand(args: string[]) {
335
+ const inputs = repeatedOption(args, "--input");
336
+ if (inputs.length < 2) {
337
+ throw new Error("merge requires at least two --input <snapshot.json> sources");
338
+ }
339
+ const out = option(args, "--out");
340
+ if (!out) throw new Error("merge requires --out <merged.json>");
341
+
342
+ const snapshots = inputs.map(
343
+ (input) =>
344
+ JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8")) as CanonicalGtmSnapshot,
345
+ );
346
+ const { snapshot, report } = mergeSnapshots(snapshots);
347
+ writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(snapshot, null, 2)}\n`);
348
+
349
+ if (args.includes("--json")) {
350
+ console.log(JSON.stringify(report, null, 2));
351
+ return;
352
+ }
353
+ console.log(`Merged ${report.sources.join(" + ")} into ${out}.`);
354
+ for (const type of ["user", "account", "contact"] as const) {
355
+ const count = report.matches.filter((match) => match.type === type).length;
356
+ console.log(` ${type} merges: ${count}`);
357
+ }
358
+ console.log(` conflicts: ${report.conflicts.length}`);
359
+ for (const conflict of report.conflicts) {
360
+ console.log(
361
+ ` ${conflict.type}/${conflict.recordId} ${conflict.field}: ${conflict.values
362
+ .map((entry) => `${entry.provider}=${JSON.stringify(entry.value)}`)
363
+ .join(" vs ")}`,
364
+ );
365
+ }
366
+ if (report.suggestions.length > 0) {
367
+ console.log(` review suggestions (same name, not auto-merged): ${report.suggestions.length}`);
368
+ for (const suggestion of report.suggestions) {
369
+ console.log(` "${suggestion.name}": ${suggestion.recordIds.join(", ")}`);
370
+ }
371
+ }
372
+ console.log(`Audit the merged view with \`fullstackgtm audit --input ${out}\`.`);
373
+ }
374
+
375
+ export async function plansCommand(args: string[]) {
376
+ const store = createFilePlanStore();
377
+ const [subcommand, ...rest] = args;
378
+
379
+ if (subcommand === "list" || subcommand === undefined) {
380
+ const status = option(rest, "--status") as
381
+ | "draft" | "needs_approval" | "approved" | "rejected" | "applied"
382
+ | null;
383
+ const plans = await store.list(status ?? undefined);
384
+ if (rest.includes("--json") || args.includes("--json")) {
385
+ console.log(
386
+ JSON.stringify(
387
+ plans.map((stored) => ({
388
+ id: stored.plan.id,
389
+ status: stored.status,
390
+ summary: stored.plan.summary,
391
+ approvedOperations: stored.approvedOperationIds.length,
392
+ runs: stored.runs.length,
393
+ createdAt: stored.createdAt,
394
+ })),
395
+ null,
396
+ 2,
397
+ ),
398
+ );
399
+ return;
400
+ }
401
+ if (plans.length === 0) {
402
+ console.log("No stored plans. Create one with `fullstackgtm audit ... --save`.");
403
+ return;
404
+ }
405
+ const p = paint(colorEnabled(process.stdout));
406
+ if (p.enabled) {
407
+ // Interactive terminals get an aligned table with status color-banding;
408
+ // the plain per-line format below is unchanged for pipes.
409
+ const rows = plans.map((stored) => [
410
+ stored.plan.id,
411
+ stored.status,
412
+ `${stored.approvedOperationIds.length} approved`,
413
+ `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
414
+ stored.plan.summary,
415
+ ]);
416
+ // Long summaries would wrap and break the table's alignment. The summary
417
+ // is the LAST column: cap it to what's left of the terminal width after
418
+ // the fixed columns and their two-space gutters (floor of 24 so narrow
419
+ // terminals still show something useful).
420
+ const columns = process.stdout.columns ?? 80;
421
+ const fixedWidth =
422
+ [0, 1, 2, 3].reduce(
423
+ (sum, index) => sum + Math.max(...rows.map((row) => row[index].length)),
424
+ 0,
425
+ ) + 2 * 4;
426
+ const summaryWidth = Math.max(24, columns - fixedWidth);
427
+ for (const row of rows) row[4] = truncateToWidth(row[4], summaryWidth);
428
+ const statusPainter = (cell: string) => {
429
+ const painted = planStatusWord(cell.trimEnd(), p);
430
+ return painted + cell.slice(cell.trimEnd().length);
431
+ };
432
+ for (const line of table(rows, [null, statusPainter, p.dim, p.dim, null])) {
433
+ console.log(line);
434
+ }
435
+ return;
436
+ }
437
+ for (const stored of plans) {
438
+ console.log(
439
+ `${stored.plan.id} ${stored.status.padEnd(14)} ${stored.plan.summary} (${stored.approvedOperationIds.length} approved, ${stored.runs.length} runs)`,
440
+ );
441
+ }
442
+ return;
443
+ }
444
+
445
+ if (subcommand === "show") {
446
+ const planId = rest.find((arg) => !arg.startsWith("--"));
447
+ if (!planId) throw new Error("Usage: fullstackgtm plans show <planId>");
448
+ const stored = await store.get(planId);
449
+ if (!stored) throw new Error(`No stored plan with id ${planId}.`);
450
+ if (rest.includes("--json")) {
451
+ console.log(JSON.stringify(stored, null, 2));
452
+ return;
453
+ }
454
+ const showPaint = paint(colorEnabled(process.stdout));
455
+ console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
456
+ console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
457
+ console.log(`Runs: ${stored.runs.length}`);
458
+ console.log("");
459
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
460
+ return;
461
+ }
462
+
463
+ if (subcommand === "approve") {
464
+ const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
465
+ if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
466
+ const operations = option(rest, "--operations");
467
+ const valuesFrom = option(rest, "--values-from");
468
+ if (!operations && !valuesFrom) {
469
+ throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
470
+ }
471
+ const stored = await store.get(planId);
472
+ if (!stored) throw new Error(`No stored plan with id ${planId}.`);
473
+
474
+ // Values from a `fullstackgtm suggest --out` file. High-confidence only by
475
+ // default; widen with --min-confidence low, opt into record-creating
476
+ // values (create:<Name>) with --include-creates. Explicit --value wins.
477
+ let fileOverrides: Record<string, string> = {};
478
+ if (valuesFrom) {
479
+ const minConfidence = option(rest, "--min-confidence") ?? "high";
480
+ if (!["high", "low"].includes(minConfidence)) {
481
+ throw new Error("--min-confidence must be high or low");
482
+ }
483
+ const { overrides, skipped } = readSuggestionValues(
484
+ valuesFrom,
485
+ minConfidence,
486
+ rest.includes("--include-creates"),
487
+ );
488
+ fileOverrides = overrides;
489
+ if (Object.keys(overrides).length === 0) {
490
+ throw new Error(
491
+ `No suggestions in ${valuesFrom} meet the confidence bar (${skipped} below it). Re-run with --min-confidence low or --include-creates, or pass explicit --value overrides.`,
492
+ );
493
+ }
494
+ if (skipped > 0) {
495
+ console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
496
+ }
497
+ }
498
+ const explicitOverrides = parseValueOverrides(rest);
499
+ const operationIds =
500
+ operations === "all"
501
+ ? stored.plan.operations.map((operation) => operation.id)
502
+ : operations
503
+ ? operations.split(",").map((id) => id.trim()).filter(Boolean)
504
+ : Object.keys(fileOverrides);
505
+ const updated = await store.approveOperations(planId, operationIds, {
506
+ ...fileOverrides,
507
+ ...explicitOverrides,
508
+ });
509
+ console.log(
510
+ `Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
511
+ );
512
+ return;
513
+ }
514
+
515
+ if (subcommand === "reject") {
516
+ const planId = rest.find((arg) => !arg.startsWith("--"));
517
+ if (!planId) throw new Error("Usage: fullstackgtm plans reject <planId>");
518
+ await store.reject(planId);
519
+ console.log(`Rejected ${planId}.`);
520
+ return;
521
+ }
522
+
523
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject"]);
524
+ }