fullstackgtm 0.45.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 (95) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +22 -10
  25. package/dist/connectors/hubspot.d.ts +7 -0
  26. package/dist/connectors/hubspot.js +236 -9
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +7 -0
  30. package/dist/connectors/salesforce.js +28 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +18 -1
  56. package/docs/api.md +2 -1
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +29 -9
  76. package/src/connectors/hubspot.ts +261 -9
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +34 -2
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +20 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The shared progress-event vocabulary — ONE set of semantics for every
3
+ * long-running process, rendered natively by each surface:
4
+ *
5
+ * CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
6
+ * app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
7
+ * both → optional broker streaming (a paired CLI POSTs heartbeats to the
8
+ * hosted app under its clientRunId, so long local runs tick live in
9
+ * the org's activity feed)
10
+ *
11
+ * Renderer-agnostic by contract: no event assumes persistence (the CLI drops
12
+ * them after painting) or ANSI (the app can't use it). Listeners are
13
+ * best-effort — a throwing renderer must never fail the work — and the
14
+ * throttle policy lives HERE so both surfaces feel identical.
15
+ *
16
+ * Standing convention: if a verb loops, it emits. New long-running verbs wire
17
+ * an emitter at the package layer; surfaces get feedback for free.
18
+ */
19
+
20
+ export type ProgressEvent =
21
+ | { kind: "stage"; stage: string; stageIndex: number; stageCount: number }
22
+ | { kind: "items"; done: number; total?: number }
23
+ | { kind: "note"; note: string }
24
+ | {
25
+ kind: "opResult";
26
+ opId: string;
27
+ status: "applied" | "skipped" | "failed" | "conflict";
28
+ detail?: string;
29
+ }
30
+ | { kind: "meter"; spent: number; budget: number; unit: string };
31
+
32
+ /** Accumulated state — what a heartbeat persists / a chip renders. */
33
+ export type ProgressSnapshot = {
34
+ stage?: string;
35
+ stageIndex?: number;
36
+ stageCount?: number;
37
+ itemsDone?: number;
38
+ itemsTotal?: number;
39
+ note?: string;
40
+ opsApplied: number;
41
+ opsSkipped: number;
42
+ opsFailed: number;
43
+ updatedAt: number;
44
+ };
45
+
46
+ export type ProgressListener = (event: ProgressEvent, snapshot: ProgressSnapshot) => void;
47
+
48
+ export type ProgressEmitter = {
49
+ stage(stage: string, stageIndex: number, stageCount: number): void;
50
+ items(done: number, total?: number): void;
51
+ note(note: string): void;
52
+ opResult(opId: string, status: "applied" | "skipped" | "failed" | "conflict", detail?: string): void;
53
+ meter(spent: number, budget: number, unit: string): void;
54
+ /** Current accumulated state (always fresh, ignoring the throttle). */
55
+ snapshot(): ProgressSnapshot;
56
+ /** Force-deliver the latest state (call at stage ends / completion). */
57
+ flush(): void;
58
+ };
59
+
60
+ const DEFAULT_THROTTLE_MS = 2000;
61
+
62
+ /**
63
+ * Throttled emitter: `items` heartbeats coalesce to at most one delivery per
64
+ * throttle window (terminal `done === total` always delivers); `stage`,
65
+ * `opResult`, `note`, and `meter` are structural and always deliver.
66
+ */
67
+ export function createProgressEmitter(
68
+ listener: ProgressListener,
69
+ options: { throttleMs?: number; now?: () => number } = {},
70
+ ): ProgressEmitter {
71
+ const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
72
+ const now = options.now ?? Date.now;
73
+ const state: ProgressSnapshot = {
74
+ opsApplied: 0,
75
+ opsSkipped: 0,
76
+ opsFailed: 0,
77
+ updatedAt: now(),
78
+ };
79
+ let lastItemsDelivery = 0;
80
+ let pendingItems = false;
81
+
82
+ function deliver(event: ProgressEvent) {
83
+ state.updatedAt = now();
84
+ try {
85
+ listener(event, { ...state });
86
+ } catch {
87
+ // Renderers are presentation-only; never fail the work.
88
+ }
89
+ }
90
+
91
+ return {
92
+ stage(stage, stageIndex, stageCount) {
93
+ state.stage = stage;
94
+ state.stageIndex = stageIndex;
95
+ state.stageCount = stageCount;
96
+ state.itemsDone = undefined;
97
+ state.itemsTotal = undefined;
98
+ state.note = undefined;
99
+ pendingItems = false;
100
+ deliver({ kind: "stage", stage, stageIndex, stageCount });
101
+ },
102
+ items(done, total) {
103
+ state.itemsDone = done;
104
+ state.itemsTotal = total;
105
+ const terminal = total !== undefined && done >= total;
106
+ const due = now() - lastItemsDelivery >= throttleMs;
107
+ if (terminal || due) {
108
+ lastItemsDelivery = now();
109
+ pendingItems = false;
110
+ deliver({ kind: "items", done, total });
111
+ } else {
112
+ pendingItems = true;
113
+ }
114
+ },
115
+ note(note) {
116
+ state.note = note;
117
+ deliver({ kind: "note", note });
118
+ },
119
+ opResult(opId, status, detail) {
120
+ if (status === "applied") state.opsApplied += 1;
121
+ else if (status === "skipped") state.opsSkipped += 1;
122
+ else state.opsFailed += 1;
123
+ deliver({ kind: "opResult", opId, status, detail });
124
+ },
125
+ meter(spent, budget, unit) {
126
+ deliver({ kind: "meter", spent, budget, unit });
127
+ },
128
+ snapshot() {
129
+ return { ...state, updatedAt: now() };
130
+ },
131
+ flush() {
132
+ if (pendingItems && state.itemsDone !== undefined) {
133
+ lastItemsDelivery = now();
134
+ pendingItems = false;
135
+ deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
136
+ }
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Fan one event stream out to several listeners (e.g. the CLI's local renderer
143
+ * plus the broker heartbeat streamer). Each listener is isolated: one throwing
144
+ * never starves the others, matching the emitter's own best-effort contract.
145
+ */
146
+ export function composeListeners(
147
+ ...listeners: Array<ProgressListener | undefined | null>
148
+ ): ProgressListener {
149
+ const active = listeners.filter((listener): listener is ProgressListener => Boolean(listener));
150
+ return (event, snapshot) => {
151
+ for (const listener of active) {
152
+ try {
153
+ listener(event, snapshot);
154
+ } catch {
155
+ // Listeners are presentation/observability only; never fail the work.
156
+ }
157
+ }
158
+ };
159
+ }
160
+
161
+ // ── Shared stage registries ─────────────────────────────────────────────────
162
+ // Package constants so the CLI checklist and the app StageTimeline render
163
+ // LITERALLY the same stages in the same order — one mental model everywhere.
164
+
165
+ export const CRM_SYNC_STAGES = [
166
+ "owners",
167
+ "accounts",
168
+ "contacts",
169
+ "deals",
170
+ "counters",
171
+ "health",
172
+ ] as const;
173
+
174
+ /** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
175
+ export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"] as const;
176
+
177
+ /** The Stripe connector's snapshot pull (billing systems have no owners). */
178
+ export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"] as const;
179
+
180
+ export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"] as const;
181
+
182
+ export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"] as const;
183
+
184
+ /** `backfill runs` — replaying local plan runs + health history to the broker. */
185
+ export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
186
+
187
+ export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
188
+
189
+ /** `enrich acquire` — routing sourced candidate rows into a create plan. */
190
+ export const ACQUIRE_STAGES = ["candidates"] as const;
191
+
192
+ export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
193
+
194
+ /** No-op emitter for callers that don't care (keeps signatures simple). */
195
+ export function nullProgressEmitter(): ProgressEmitter {
196
+ return createProgressEmitter(() => {});
197
+ }
package/src/runReport.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * pure added value for users who granted it.
9
9
  */
10
10
  import { getCredential } from "./credentials.ts";
11
+ import type { ProgressListener, ProgressSnapshot } from "./progress.ts";
11
12
 
12
13
  type RunEvent = { ts: number; type: string; detail?: string };
13
14
 
@@ -61,6 +62,16 @@ export function reportEvent(type: string, detail?: string): void {
61
62
  events.push({ ts: Date.now(), type, detail });
62
63
  }
63
64
 
65
+ // FNV-1a — the stable-id hash used across the package (see backfill.ts).
66
+ function fnv1a(value: string): string {
67
+ let hash = 0x811c9dc5;
68
+ for (let i = 0; i < value.length; i += 1) {
69
+ hash ^= value.charCodeAt(i);
70
+ hash = Math.imul(hash, 0x01000193);
71
+ }
72
+ return (hash >>> 0).toString(16).padStart(8, "0");
73
+ }
74
+
64
75
  // Setup/inspection verbs aren't interesting as "runs" — skip the noise.
65
76
  const SKIP_COMMANDS = new Set([
66
77
  "login",
@@ -74,6 +85,144 @@ const SKIP_COMMANDS = new Set([
74
85
  "-v",
75
86
  ]);
76
87
 
88
+ /** The stable per-invocation identity flushRunReport and heartbeats share. */
89
+ function runIdentity(
90
+ args: string[],
91
+ startedAt: number,
92
+ ): { command: string; clientRunId: string } | null {
93
+ const command = args[0];
94
+ if (!command || SKIP_COMMANDS.has(command)) return null;
95
+ const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
96
+ const full = `${command}${sub}`;
97
+ return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
98
+ }
99
+
100
+ // ── Live progress streaming ─────────────────────────────────────────────────
101
+ // While a long command runs, a paired CLI streams heartbeats to the hosted
102
+ // app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
103
+ // will use, status "in_progress", and the progress emitter's snapshot. The
104
+ // server upserts by clientRunId, so the terminal flush simply wins. Same
105
+ // invariants as flushRunReport: opt-in (paired only), fire-and-forget,
106
+ // time-capped, never affects the command's outcome or output.
107
+
108
+ const HEARTBEAT_INTERVAL_MS = 5000;
109
+ /** No heartbeat before the run is ~this old — fast commands never stream. */
110
+ const HEARTBEAT_MIN_RUN_MS = 5000;
111
+ const HEARTBEAT_TIMEOUT_MS = 2000;
112
+
113
+ type RunContext = { command: string; clientRunId: string; startedAt: number };
114
+
115
+ let runContext: RunContext | null = null;
116
+ let lastHeartbeatAt = 0;
117
+ // Broker credential resolved once per run (undefined = not looked up yet;
118
+ // null = looked up, not paired).
119
+ let heartbeatBroker: { baseUrl: string; accessToken: string } | null | undefined;
120
+ let inflightHeartbeat: AbortController | null = null;
121
+
122
+ /**
123
+ * Arm heartbeat streaming for this invocation. Call once from the entry point
124
+ * (alongside capturing `startedAt`); without it, progress listeners from
125
+ * `progressReporter()` are inert. Safe to call for any command — skip-listed
126
+ * commands simply never stream.
127
+ */
128
+ export function beginRunReport(args: string[], startedAt: number): void {
129
+ lastHeartbeatAt = 0;
130
+ heartbeatBroker = undefined;
131
+ inflightHeartbeat = null;
132
+ const identity = runIdentity(args, startedAt);
133
+ runContext = identity ? { ...identity, startedAt } : null;
134
+ }
135
+
136
+ /**
137
+ * A ProgressListener that streams the run's progress snapshot to the paired
138
+ * hosted app. Verbs compose it with their local renderer:
139
+ *
140
+ * createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
141
+ *
142
+ * Throttled to one POST per ~5s, first no earlier than ~5s into the run,
143
+ * 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
144
+ * must stay generic ("batch 4/12"); CRM field values never leave the machine.
145
+ */
146
+ export function progressReporter(
147
+ overrides: {
148
+ now?: () => number;
149
+ intervalMs?: number;
150
+ minRunMs?: number;
151
+ fetchImpl?: typeof fetch;
152
+ } = {},
153
+ ): ProgressListener {
154
+ const now = overrides.now ?? Date.now;
155
+ const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
156
+ const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
157
+ const fetchImpl = overrides.fetchImpl ?? fetch;
158
+ return (_event, snapshot) => {
159
+ try {
160
+ if (!runContext) return;
161
+ const at = now();
162
+ if (at - runContext.startedAt < minRunMs) return;
163
+ if (at - lastHeartbeatAt < intervalMs) return;
164
+ if (heartbeatBroker === undefined) {
165
+ const broker = getCredential("broker");
166
+ heartbeatBroker =
167
+ broker?.baseUrl && broker.accessToken
168
+ ? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
169
+ : null; // opt-in: only when paired
170
+ }
171
+ if (!heartbeatBroker) return;
172
+ lastHeartbeatAt = at;
173
+ sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
174
+ } catch {
175
+ // Observability is best-effort; never affect the command.
176
+ }
177
+ };
178
+ }
179
+
180
+ function sendHeartbeat(
181
+ fetchImpl: typeof fetch,
182
+ broker: { baseUrl: string; accessToken: string },
183
+ context: RunContext,
184
+ snapshot: ProgressSnapshot,
185
+ at: number,
186
+ ): void {
187
+ // One heartbeat in flight at a time: a stalled POST is superseded, and the
188
+ // terminal flush aborts any straggler so it can never delay process exit.
189
+ inflightHeartbeat?.abort();
190
+ const controller = new AbortController();
191
+ inflightHeartbeat = controller;
192
+ const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
193
+ (timer as { unref?: () => void }).unref?.();
194
+ void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
195
+ method: "POST",
196
+ headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
197
+ body: JSON.stringify({
198
+ command: context.command,
199
+ clientRunId: context.clientRunId,
200
+ status: "in_progress",
201
+ startedAt: context.startedAt,
202
+ finishedAt: at,
203
+ durationMs: at - context.startedAt,
204
+ progress: {
205
+ // Stage names + counts only — never CRM field values.
206
+ stage: snapshot.stage,
207
+ stageIndex: snapshot.stageIndex,
208
+ stageCount: snapshot.stageCount,
209
+ itemsDone: snapshot.itemsDone,
210
+ itemsTotal: snapshot.itemsTotal,
211
+ note: snapshot.note,
212
+ heartbeatAt: at,
213
+ },
214
+ }),
215
+ signal: controller.signal,
216
+ })
217
+ .catch(() => {
218
+ // fire-and-forget
219
+ })
220
+ .finally(() => {
221
+ clearTimeout(timer);
222
+ if (inflightHeartbeat === controller) inflightHeartbeat = null;
223
+ });
224
+ }
225
+
77
226
  /**
78
227
  * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
79
228
  * swallows every error. Call once per process from the entry point.
@@ -84,19 +233,25 @@ export async function flushRunReport(
84
233
  startedAt: number,
85
234
  error?: string,
86
235
  ): Promise<void> {
87
- const command = args[0];
88
- if (!command || SKIP_COMMANDS.has(command)) return;
236
+ // The terminal upsert supersedes any streaming heartbeat still in flight.
237
+ inflightHeartbeat?.abort();
238
+ inflightHeartbeat = null;
239
+ // Stable per-invocation identity: the server dedupes on clientRunId, so a
240
+ // retried report, an "in_progress" heartbeat, or a later `backfill runs`
241
+ // replay of local history never duplicates.
242
+ const identity = runIdentity(args, startedAt);
243
+ if (!identity) return;
89
244
  const broker = getCredential("broker");
90
245
  if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
91
246
 
92
- const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
93
247
  const finishedAt = Date.now();
94
248
  try {
95
249
  await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
96
250
  method: "POST",
97
251
  headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
98
252
  body: JSON.stringify({
99
- command: `${command}${sub}`,
253
+ command: identity.command,
254
+ clientRunId: identity.clientRunId,
100
255
  status,
101
256
  startedAt,
102
257
  finishedAt,
package/src/types.ts CHANGED
@@ -48,7 +48,8 @@ export type PatchOperationType =
48
48
  // on matchKey at apply time (search is the source of truth; the plan-time
49
49
  // snapshot can be stale) and creates only on a confirmed miss, so apply is
50
50
  // resolve-first and never double-creates a record a concurrent writer added.
51
- // Emitted only by `enrich acquire`, and metered against the acquire budget.
51
+ // Emitted by `enrich acquire` (metered against the acquire budget) and by
52
+ // `backfill stripe` (closed-won deals from paid invoices, unmetered).
52
53
  | "create_record"
53
54
  // Merge a duplicate group into a survivor. beforeValue is the group's
54
55
  // record ids; afterValue is the survivor id (requires_human_survivor_selection
@@ -59,7 +60,8 @@ export type PatchOperationType =
59
60
  * The afterValue of a `create_record` operation. The connector re-resolves on
60
61
  * `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
61
62
  * `estCostUsd` is the acquire meter's per-record charge, recorded against the
62
- * budget on a successful create.
63
+ * budget on a successful create. Emitted by `enrich acquire` (metered) and
64
+ * `backfill stripe` (closed-won deals from paid invoices, unmetered).
63
65
  */
64
66
  export type CreateRecordPayload = {
65
67
  properties: Record<string, string>;
@@ -83,6 +85,22 @@ export type CreateRecordPayload = {
83
85
  ownerId?: string;
84
86
  /** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
85
87
  assignedBy?: string;
88
+ /**
89
+ * Deal creates only: provider-neutral stage sentinel. "closed_won" tells the
90
+ * connector to resolve the target pipeline's REAL closed-won stage id from
91
+ * the provider's pipeline metadata at apply time (HubSpot: stage with
92
+ * `metadata.isClosed` true and probability 1) — never by substring-guessing
93
+ * a stage name. If no such stage is resolvable the operation is skipped.
94
+ * Plain deal properties (amount, closedate, dealname, …) travel in
95
+ * `properties` as provider property names, like every other create.
96
+ */
97
+ dealStage?: "closed_won";
98
+ /**
99
+ * Deal creates only: which pipeline to create the deal in — a pipeline id
100
+ * or a case-insensitive pipeline label. Absent = the portal's default
101
+ * pipeline (lowest displayOrder).
102
+ */
103
+ dealPipeline?: string;
86
104
  };
87
105
 
88
106
  export type AuditFindingSeverity = "info" | "warning" | "critical";
@@ -1,87 +0,0 @@
1
- # DX / adoption punch list
2
-
3
- Audit date: 2026-06-19. Method: ran the CLI as a new adopter would (bare
4
- invoke → `doctor` → `audit --demo` → per-command `--help`) and measured the
5
- friction, rather than reading the source. Companion to the security/strategic
6
- findings that drove the 0.25.1–0.28.0 hardening releases — same format, same
7
- intent: a tracked list that drives a release.
8
-
9
- ## Framing
10
-
11
- The **agent path** (SKILL.md verb map, MCP tools, `--json` everywhere,
12
- gate-shaped exit codes 0/1/2) is cohesive and adopts cleanly. The **human
13
- path** is the weak side — and the human is a primary user (the consultant-CLI,
14
- dogfood-on-client-work direction). Every fix below either *deepens an existing
15
- verb* or *connects two existing verbs*; none adds a new top-level noun. The
16
- adoption work **is** the "deeper not broader" work.
17
-
18
- `doctor` (ends with "Next step") and `fix` (chains audit→suggest→approve→apply)
19
- already do the right thing. Cohesion here = make every verb behave like
20
- `doctor` and `fix` already do.
21
-
22
- ## Findings (evidence-backed)
23
-
24
- | # | Finding | Evidence | Impact |
25
- | --- | --- | --- | --- |
26
- | F1 | **Front door is a firehose** | bare invoke = 194 lines; 22 verbs / 87 distinct flags; `cli.ts` is 3,615 lines | New user can't find the ~6 things they actually do |
27
- | F2 | **Credential-free entry point dead-ends** | `audit --demo` (the README's "try it first") dumps **1,467 lines** to stdout and ends on a blank line — no "next step," unlike `doctor` | The single most-run first command strands you |
28
- | F3 | **No per-command help** | `dedupe --help`, `audit --help` fall through to the same 194-line global wall | A stuck user gets the firehose, not the answer |
29
- | F4 | **No altitude control on reads** | `audit` has only `--json` / `--out` / `--format`; no `--summary` / `--top` / `--quiet`. Human mode *is* the 1,467-line dump | Default human output is unreadable yet not machine-parseable |
30
- | F5 | **Tone undercuts positioning** | `audit --demo` footer: "This prototype is dry-run only" | Reads as toy vs. the beta/production-safety-invariant framing |
31
- | F6 | **No guided mode** | `readline` is used only for the login secret prompt | Every workflow is hand-assembled from flags; nothing scaffolds the loop |
32
-
33
- ## Fix sequence (highest leverage first)
34
-
35
- ### 1. Progressive-disclosure help — IN PROGRESS
36
- Fixes **F1, F3**. The full `usage()` becomes the explicit full reference
37
- (`fullstackgtm help --full`). Add:
38
- - `shortUsage()` — a ~30-line map of the verbs grouped by lifecycle phase
39
- (Setup · Detect · Prevent · Remediate · Calls · Govern · Market · Schedule),
40
- one line each, ending with `<command> --help` and `help --full` pointers.
41
- Becomes the default for bare invoke and `--help`.
42
- - `commandHelp(command)` — focused per-verb help (summary, synopsis, where it
43
- sits in the loop, key options, see-also). `<verb> --help` zooms in instead
44
- of dumping the surface. `call`/`market`/`enrich`/`bulk-update`/`schedule`
45
- keep their existing bespoke help.
46
- - `help [command] [--full]` command.
47
-
48
- ### 2. Every verb hands you the next step — DONE (audit keystone)
49
- Fixes **F2**. `auditNextStep()` gives `audit` context-aware forward guidance on
50
- stderr (stdout stays clean for pipes/`--out`; suppressed under `--json`):
51
- - **demo/sample** → "Client-ready writeup: `report --demo`" + "Run on your CRM:
52
- `login hubspot` → `audit --provider hubspot --save`"
53
- - **live + `--save`** → the governed spine: `suggest` → `plans approve
54
- --values-from` → `apply`, with the real plan id
55
- - **0 findings** → "clean — gate new records with `resolve`, schedule a check"
56
-
57
- The lifecycle spine made *experiential* — each command knows its phase and
58
- points forward (`doctor`/`fix` were the templates). `audit` is the keystone
59
- (the documented dead-end); the helper pattern now generalizes to the other read
60
- verbs (`report`, `resolve`, `dedupe` previews) in a follow-up.
61
-
62
- ### 3. Altitude control on the firehose — DONE
63
- Fixes **F4**. `patchPlanToMarkdown(plan, { summary })` gains a summary view
64
- (header + Findings-by-Rule table + operation count + pointers to `--full` and
65
- `report`). `audit` defaults to it — **1,467 → 24 lines** on the demo — with
66
- `--full` to opt back into the per-operation dump. The default stays *full* for
67
- every other caller (bulk-update, fix, dedupe, plans show, MCP): write-preview
68
- verbs keep full operation detail because you approve specific ops there. The
69
- principled split — audit = "what's wrong" (summary → `report`); write verbs =
70
- "exactly what I'll change" (full) — is the real win.
71
-
72
- ### 4. Tone pass — DONE
73
- Fixes **F5**. The "This prototype is dry-run only" footer is replaced
74
- everywhere with safety-invariant framing matching README/SKILL: "Dry-run plan —
75
- read-only. No provider write happens until you approve specific operations and
76
- run `apply`; placeholder values are refused without an explicit override. These
77
- safety invariants are not beta." No "prototype" string remains in user output.
78
-
79
- ## Deferred (bigger bets, gated on 1–4 landing)
80
- - **Engagement workspace**: per-client `.fullstackgtm/` state that accumulates
81
- snapshots, plans, evidence, and a health-over-time score — turns episodic
82
- audits into a continuously-operated system.
83
- - **Journey commands**: 2–3 opinionated composites (onboard-a-client,
84
- health-rollup) sequencing existing verbs into revops jobs.
85
-
86
- There is no point building the engagement workspace while the front door dumps
87
- 1,467 lines. Land the DX fixes first.