fullstackgtm 0.45.0 → 0.47.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 +115 -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 +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -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 +33 -1
  56. package/docs/api.md +4 -2
  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 +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  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 +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
package/src/mcp.ts CHANGED
@@ -53,6 +53,8 @@ import {
53
53
  import { generateDemoSnapshot } from "./demo.ts";
54
54
  import type { FieldMappings } from "./mappings.ts";
55
55
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
56
+ import { verifyApprovalDigests } from "./integrity.ts";
57
+ import { createFilePlanStore, type StoredPlan } from "./planStore.ts";
56
58
  import { builtinAuditRules } from "./rules.ts";
57
59
  import { sampleSnapshot } from "./sampleData.ts";
58
60
  import { normalizeTranscript, parseCall } from "./calls.ts";
@@ -322,7 +324,11 @@ const toolDefinitions: ToolDefinition[] = [
322
324
  description:
323
325
  "Apply explicitly approved operations from a patch plan through a provider " +
324
326
  "connector. Operations not listed in approvedOperationIds are never written, " +
325
- "and requires_human_* placeholders need a value override.",
327
+ "and requires_human_* placeholders need a value override. When the plan is in " +
328
+ "the local plan store (saved via `audit --save`), approvals are verified against " +
329
+ "the store's HMAC approval digests — ids not approved with `plans approve` are " +
330
+ "refused — and the run is recorded onto the stored plan so `plans show` and " +
331
+ "`audit-log export` include it.",
326
332
  inputSchema: {
327
333
  provider: z.enum(["hubspot", "salesforce"]),
328
334
  planPath: z.string(),
@@ -332,14 +338,128 @@ const toolDefinitions: ToolDefinition[] = [
332
338
  },
333
339
  },
334
340
  handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
335
- const plan = JSON.parse(
336
- readFileSync(resolve(process.cwd(), planPath), "utf8"),
337
- ) as PatchPlan;
341
+ // The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
342
+ // (a file straight out of the plan-store directory).
343
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as unknown;
344
+ const filePlan = isStoredPlanFile(parsed) ? parsed.plan : (parsed as PatchPlan);
345
+ if (!filePlan || !Array.isArray(filePlan.operations)) {
346
+ throw new Error(`${planPath} is not a patch plan (expected { id, operations: [...] }).`);
347
+ }
348
+
349
+ // Governance parity with CLI apply: when this plan id exists in the local
350
+ // plan store, the STORE is the source of truth — the approved-id set must
351
+ // come from `plans approve` (which HMAC-signed each approved op), the
352
+ // signatures are re-verified here, and the run is recorded back onto the
353
+ // plan so audit-log export sees MCP applies exactly like CLI applies.
354
+ const store = createFilePlanStore();
355
+ const stored =
356
+ typeof filePlan.id === "string" && /^[\w.-]+$/.test(filePlan.id)
357
+ ? await store.get(filePlan.id)
358
+ : null;
359
+
360
+ let plan: PatchPlan;
361
+ let effectiveOverrides: Record<string, unknown>;
362
+ if (stored) {
363
+ if (stored.status !== "approved") {
364
+ throw new Error(
365
+ `Plan ${filePlan.id} is ${stored.status}; approve operations first with ` +
366
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
367
+ );
368
+ }
369
+ // Downgrade guard (same as CLI apply): an approved plan with no
370
+ // signatures either predates 0.26 or had approvalDigests stripped to
371
+ // skip the integrity check. Refuse rather than trust the file.
372
+ if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
373
+ throw new Error(
374
+ `Refusing to apply plan ${filePlan.id}: it was approved without integrity signatures ` +
375
+ "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
376
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
377
+ );
378
+ }
379
+ // Never widen: every id the tool wants applied must already be
380
+ // store-approved. (A subset is fine — narrowing never writes more.)
381
+ const storeApproved = new Set(stored.approvedOperationIds);
382
+ const unapproved = approvedOperationIds.filter((id: string) => !storeApproved.has(id));
383
+ if (unapproved.length > 0) {
384
+ throw new Error(
385
+ `Refusing to apply plan ${filePlan.id}: operation(s) ${unapproved.join(", ")} were never ` +
386
+ "approved in the plan store. Approve them first with " +
387
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids>\`.`,
388
+ );
389
+ }
390
+ // Integrity gate: verify against the EFFECTIVE overrides (stored ∪ tool
391
+ // args), so a tool-supplied value that changes what the human approved
392
+ // is treated as tamper, not a live override — what gets written must
393
+ // equal what was signed.
394
+ effectiveOverrides = { ...stored.valueOverrides, ...(valueOverrides ?? {}) };
395
+ const verification = verifyApprovalDigests(
396
+ stored.plan.operations,
397
+ stored.approvedOperationIds,
398
+ effectiveOverrides,
399
+ stored.approvalDigests,
400
+ );
401
+ if (!verification.ok) {
402
+ const detail =
403
+ verification.reason === "no_key"
404
+ ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
405
+ : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
406
+ "If you want a different value, set it at approval (`plans approve --value <op>=<v>`) and re-approve; " +
407
+ "otherwise the plan was edited after approval — review and re-approve.";
408
+ throw new Error(`Refusing to apply plan ${filePlan.id}: ${detail}`);
409
+ }
410
+ plan = stored.plan; // apply what was signed, not what the file says now
411
+ } else {
412
+ // External plan file, not in the store: no digests exist to verify, but
413
+ // approvals must still reference real operations in the plan content.
414
+ plan = filePlan;
415
+ const known = new Set(plan.operations.map((operation) => operation.id));
416
+ const unknown = approvedOperationIds.filter((id: string) => !known.has(id));
417
+ if (unknown.length > 0) {
418
+ throw new Error(
419
+ `Plan ${planPath} has no operation(s) ${unknown.join(", ")} — approvedOperationIds ` +
420
+ "must reference operations in the plan.",
421
+ );
422
+ }
423
+ effectiveOverrides = valueOverrides ?? {};
424
+ }
425
+
338
426
  const run = await applyPatchPlan(await connectorFor(provider), plan, {
339
427
  approvedOperationIds,
340
- valueOverrides,
428
+ valueOverrides: effectiveOverrides,
341
429
  });
342
- return content(output === "markdown" ? formatPatchPlanRun(run) : run);
430
+
431
+ // Persist the run (same data the CLI records) so runs[] and plan status
432
+ // update and audit-log export includes MCP applies. External plan files
433
+ // have no store entry to update — say so instead of silently dropping it.
434
+ let runRecorded = false;
435
+ if (stored) {
436
+ await store.recordRun(plan.id, run);
437
+ runRecorded = true;
438
+ }
439
+ const governance = {
440
+ planSource: stored ? ("store" as const) : ("external" as const),
441
+ approvalDigestsVerified: stored !== null,
442
+ runRecorded,
443
+ ...(stored
444
+ ? {}
445
+ : {
446
+ note:
447
+ "Plan file is not in the local plan store: approvals came from tool arguments " +
448
+ "(verified against the plan content only — no approval-digest check) and the run " +
449
+ "was not recorded. Use `fullstackgtm audit --save` + `plans approve` for " +
450
+ "store-backed governance.",
451
+ }),
452
+ };
453
+ if (output === "markdown") {
454
+ return content(
455
+ `${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
456
+ (runRecorded
457
+ ? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
458
+ : governance.note!),
459
+ );
460
+ }
461
+ // Backward compatible: the run's own fields stay top-level; governance is additive.
462
+ return content({ ...run, governance });
343
463
  },
344
464
  },
345
465
  {
@@ -456,6 +576,20 @@ export async function startMcpServer() {
456
576
  await server.connect(transport);
457
577
  }
458
578
 
579
+ /**
580
+ * A plan-store file is a StoredPlan envelope ({ plan, status, runs, ... });
581
+ * `audit --out` writes a bare PatchPlan. fullstackgtm_apply accepts either.
582
+ */
583
+ function isStoredPlanFile(value: unknown): value is StoredPlan {
584
+ if (typeof value !== "object" || value === null || !("plan" in value)) return false;
585
+ const plan = (value as { plan?: unknown }).plan;
586
+ return (
587
+ typeof plan === "object" &&
588
+ plan !== null &&
589
+ Array.isArray((plan as { operations?: unknown }).operations)
590
+ );
591
+ }
592
+
459
593
  function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
460
594
  try {
461
595
  return loadMarketConfig(path);
@@ -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";
@@ -505,6 +523,21 @@ export type GtmConnector = {
505
523
  * result per input op. Optional: connectors without it use `applyOperation`.
506
524
  */
507
525
  applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
526
+ /**
527
+ * Optional bulk apply path for homogeneous operation runs selected by
528
+ * `applyPatchPlan` (currently consecutive `set_field` ops on one object type
529
+ * and consecutive `archive_record` ops on one object type). Implementations
530
+ * MUST return exactly one result per input operation, preserve per-operation
531
+ * CAS/conflict semantics for field writes, and must not write operations that
532
+ * would have conflicted under the serial `readField` + `applyOperation` path.
533
+ * Connectors without it use `applyOperation` per operation.
534
+ */
535
+ applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
536
+ /**
537
+ * Maximum number of operations `applyPatchPlan` should pass to one
538
+ * `applyBatch` call. Defaults to 200 when unspecified.
539
+ */
540
+ applyBatchLimit?: number;
508
541
  /**
509
542
  * Read the live value of one canonical field, used for compare-and-set:
510
543
  * apply orchestration refuses to write over values that drifted since the