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,96 @@
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
+ export type ProgressEvent = {
20
+ kind: "stage";
21
+ stage: string;
22
+ stageIndex: number;
23
+ stageCount: number;
24
+ } | {
25
+ kind: "items";
26
+ done: number;
27
+ total?: number;
28
+ } | {
29
+ kind: "note";
30
+ note: string;
31
+ } | {
32
+ kind: "opResult";
33
+ opId: string;
34
+ status: "applied" | "skipped" | "failed" | "conflict";
35
+ detail?: string;
36
+ } | {
37
+ kind: "meter";
38
+ spent: number;
39
+ budget: number;
40
+ unit: string;
41
+ };
42
+ /** Accumulated state — what a heartbeat persists / a chip renders. */
43
+ export type ProgressSnapshot = {
44
+ stage?: string;
45
+ stageIndex?: number;
46
+ stageCount?: number;
47
+ itemsDone?: number;
48
+ itemsTotal?: number;
49
+ note?: string;
50
+ opsApplied: number;
51
+ opsSkipped: number;
52
+ opsFailed: number;
53
+ updatedAt: number;
54
+ };
55
+ export type ProgressListener = (event: ProgressEvent, snapshot: ProgressSnapshot) => void;
56
+ export type ProgressEmitter = {
57
+ stage(stage: string, stageIndex: number, stageCount: number): void;
58
+ items(done: number, total?: number): void;
59
+ note(note: string): void;
60
+ opResult(opId: string, status: "applied" | "skipped" | "failed" | "conflict", detail?: string): void;
61
+ meter(spent: number, budget: number, unit: string): void;
62
+ /** Current accumulated state (always fresh, ignoring the throttle). */
63
+ snapshot(): ProgressSnapshot;
64
+ /** Force-deliver the latest state (call at stage ends / completion). */
65
+ flush(): void;
66
+ };
67
+ /**
68
+ * Throttled emitter: `items` heartbeats coalesce to at most one delivery per
69
+ * throttle window (terminal `done === total` always delivers); `stage`,
70
+ * `opResult`, `note`, and `meter` are structural and always deliver.
71
+ */
72
+ export declare function createProgressEmitter(listener: ProgressListener, options?: {
73
+ throttleMs?: number;
74
+ now?: () => number;
75
+ }): ProgressEmitter;
76
+ /**
77
+ * Fan one event stream out to several listeners (e.g. the CLI's local renderer
78
+ * plus the broker heartbeat streamer). Each listener is isolated: one throwing
79
+ * never starves the others, matching the emitter's own best-effort contract.
80
+ */
81
+ export declare function composeListeners(...listeners: Array<ProgressListener | undefined | null>): ProgressListener;
82
+ export declare const CRM_SYNC_STAGES: readonly ["owners", "accounts", "contacts", "deals", "counters", "health"];
83
+ /** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
84
+ export declare const SNAPSHOT_PULL_STAGES: readonly ["owners", "accounts", "contacts", "deals"];
85
+ /** The Stripe connector's snapshot pull (billing systems have no owners). */
86
+ export declare const STRIPE_SNAPSHOT_STAGES: readonly ["customers", "subscriptions"];
87
+ export declare const CALL_SYNC_STAGES: readonly ["notes", "transcripts", "insights"];
88
+ export declare const BACKFILL_STRIPE_STAGES: readonly ["invoices", "snapshot", "matching", "plan"];
89
+ /** `backfill runs` — replaying local plan runs + health history to the broker. */
90
+ export declare const RUNS_REPLAY_STAGES: readonly ["runs", "health"];
91
+ export declare const APPLY_STAGES: readonly ["preflight", "operations", "results"];
92
+ /** `enrich acquire` — routing sourced candidate rows into a create plan. */
93
+ export declare const ACQUIRE_STAGES: readonly ["candidates"];
94
+ export declare const MARKET_CAPTURE_STAGES: readonly ["sources", "capture", "classify", "persist"];
95
+ /** No-op emitter for callers that don't care (keeps signatures simple). */
96
+ export declare function nullProgressEmitter(): ProgressEmitter;
@@ -0,0 +1,142 @@
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
+ const DEFAULT_THROTTLE_MS = 2000;
20
+ /**
21
+ * Throttled emitter: `items` heartbeats coalesce to at most one delivery per
22
+ * throttle window (terminal `done === total` always delivers); `stage`,
23
+ * `opResult`, `note`, and `meter` are structural and always deliver.
24
+ */
25
+ export function createProgressEmitter(listener, options = {}) {
26
+ const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
27
+ const now = options.now ?? Date.now;
28
+ const state = {
29
+ opsApplied: 0,
30
+ opsSkipped: 0,
31
+ opsFailed: 0,
32
+ updatedAt: now(),
33
+ };
34
+ let lastItemsDelivery = 0;
35
+ let pendingItems = false;
36
+ function deliver(event) {
37
+ state.updatedAt = now();
38
+ try {
39
+ listener(event, { ...state });
40
+ }
41
+ catch {
42
+ // Renderers are presentation-only; never fail the work.
43
+ }
44
+ }
45
+ return {
46
+ stage(stage, stageIndex, stageCount) {
47
+ state.stage = stage;
48
+ state.stageIndex = stageIndex;
49
+ state.stageCount = stageCount;
50
+ state.itemsDone = undefined;
51
+ state.itemsTotal = undefined;
52
+ state.note = undefined;
53
+ pendingItems = false;
54
+ deliver({ kind: "stage", stage, stageIndex, stageCount });
55
+ },
56
+ items(done, total) {
57
+ state.itemsDone = done;
58
+ state.itemsTotal = total;
59
+ const terminal = total !== undefined && done >= total;
60
+ const due = now() - lastItemsDelivery >= throttleMs;
61
+ if (terminal || due) {
62
+ lastItemsDelivery = now();
63
+ pendingItems = false;
64
+ deliver({ kind: "items", done, total });
65
+ }
66
+ else {
67
+ pendingItems = true;
68
+ }
69
+ },
70
+ note(note) {
71
+ state.note = note;
72
+ deliver({ kind: "note", note });
73
+ },
74
+ opResult(opId, status, detail) {
75
+ if (status === "applied")
76
+ state.opsApplied += 1;
77
+ else if (status === "skipped")
78
+ state.opsSkipped += 1;
79
+ else
80
+ state.opsFailed += 1;
81
+ deliver({ kind: "opResult", opId, status, detail });
82
+ },
83
+ meter(spent, budget, unit) {
84
+ deliver({ kind: "meter", spent, budget, unit });
85
+ },
86
+ snapshot() {
87
+ return { ...state, updatedAt: now() };
88
+ },
89
+ flush() {
90
+ if (pendingItems && state.itemsDone !== undefined) {
91
+ lastItemsDelivery = now();
92
+ pendingItems = false;
93
+ deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
94
+ }
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Fan one event stream out to several listeners (e.g. the CLI's local renderer
100
+ * plus the broker heartbeat streamer). Each listener is isolated: one throwing
101
+ * never starves the others, matching the emitter's own best-effort contract.
102
+ */
103
+ export function composeListeners(...listeners) {
104
+ const active = listeners.filter((listener) => Boolean(listener));
105
+ return (event, snapshot) => {
106
+ for (const listener of active) {
107
+ try {
108
+ listener(event, snapshot);
109
+ }
110
+ catch {
111
+ // Listeners are presentation/observability only; never fail the work.
112
+ }
113
+ }
114
+ };
115
+ }
116
+ // ── Shared stage registries ─────────────────────────────────────────────────
117
+ // Package constants so the CLI checklist and the app StageTimeline render
118
+ // LITERALLY the same stages in the same order — one mental model everywhere.
119
+ export const CRM_SYNC_STAGES = [
120
+ "owners",
121
+ "accounts",
122
+ "contacts",
123
+ "deals",
124
+ "counters",
125
+ "health",
126
+ ];
127
+ /** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
128
+ export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"];
129
+ /** The Stripe connector's snapshot pull (billing systems have no owners). */
130
+ export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"];
131
+ export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"];
132
+ export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"];
133
+ /** `backfill runs` — replaying local plan runs + health history to the broker. */
134
+ export const RUNS_REPLAY_STAGES = ["runs", "health"];
135
+ export const APPLY_STAGES = ["preflight", "operations", "results"];
136
+ /** `enrich acquire` — routing sourced candidate rows into a create plan. */
137
+ export const ACQUIRE_STAGES = ["candidates"];
138
+ export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"];
139
+ /** No-op emitter for callers that don't care (keeps signatures simple). */
140
+ export function nullProgressEmitter() {
141
+ return createProgressEmitter(() => { });
142
+ }
@@ -1,3 +1,4 @@
1
+ import type { ProgressListener } from "./progress.ts";
1
2
  /**
2
3
  * Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
3
4
  * values ever leave the CLI, keeping the audit's row data on the operator's side
@@ -28,6 +29,29 @@ export declare function reportFindings(values: RunFinding[]): void;
28
29
  export declare function reportCrm(value: RunCrm): void;
29
30
  /** A command annotates a structured event (plan saved, meter charged, …). */
30
31
  export declare function reportEvent(type: string, detail?: string): void;
32
+ /**
33
+ * Arm heartbeat streaming for this invocation. Call once from the entry point
34
+ * (alongside capturing `startedAt`); without it, progress listeners from
35
+ * `progressReporter()` are inert. Safe to call for any command — skip-listed
36
+ * commands simply never stream.
37
+ */
38
+ export declare function beginRunReport(args: string[], startedAt: number): void;
39
+ /**
40
+ * A ProgressListener that streams the run's progress snapshot to the paired
41
+ * hosted app. Verbs compose it with their local renderer:
42
+ *
43
+ * createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
44
+ *
45
+ * Throttled to one POST per ~5s, first no earlier than ~5s into the run,
46
+ * 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
47
+ * must stay generic ("batch 4/12"); CRM field values never leave the machine.
48
+ */
49
+ export declare function progressReporter(overrides?: {
50
+ now?: () => number;
51
+ intervalMs?: number;
52
+ minRunMs?: number;
53
+ fetchImpl?: typeof fetch;
54
+ }): ProgressListener;
31
55
  /**
32
56
  * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
33
57
  * swallows every error. Call once per process from the entry point.
package/dist/runReport.js CHANGED
@@ -31,6 +31,15 @@ export function reportCrm(value) {
31
31
  export function reportEvent(type, detail) {
32
32
  events.push({ ts: Date.now(), type, detail });
33
33
  }
34
+ // FNV-1a — the stable-id hash used across the package (see backfill.ts).
35
+ function fnv1a(value) {
36
+ let hash = 0x811c9dc5;
37
+ for (let i = 0; i < value.length; i += 1) {
38
+ hash ^= value.charCodeAt(i);
39
+ hash = Math.imul(hash, 0x01000193);
40
+ }
41
+ return (hash >>> 0).toString(16).padStart(8, "0");
42
+ }
34
43
  // Setup/inspection verbs aren't interesting as "runs" — skip the noise.
35
44
  const SKIP_COMMANDS = new Set([
36
45
  "login",
@@ -43,25 +52,151 @@ const SKIP_COMMANDS = new Set([
43
52
  "--version",
44
53
  "-v",
45
54
  ]);
55
+ /** The stable per-invocation identity flushRunReport and heartbeats share. */
56
+ function runIdentity(args, startedAt) {
57
+ const command = args[0];
58
+ if (!command || SKIP_COMMANDS.has(command))
59
+ return null;
60
+ const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
61
+ const full = `${command}${sub}`;
62
+ return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
63
+ }
64
+ // ── Live progress streaming ─────────────────────────────────────────────────
65
+ // While a long command runs, a paired CLI streams heartbeats to the hosted
66
+ // app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
67
+ // will use, status "in_progress", and the progress emitter's snapshot. The
68
+ // server upserts by clientRunId, so the terminal flush simply wins. Same
69
+ // invariants as flushRunReport: opt-in (paired only), fire-and-forget,
70
+ // time-capped, never affects the command's outcome or output.
71
+ const HEARTBEAT_INTERVAL_MS = 5000;
72
+ /** No heartbeat before the run is ~this old — fast commands never stream. */
73
+ const HEARTBEAT_MIN_RUN_MS = 5000;
74
+ const HEARTBEAT_TIMEOUT_MS = 2000;
75
+ let runContext = null;
76
+ let lastHeartbeatAt = 0;
77
+ // Broker credential resolved once per run (undefined = not looked up yet;
78
+ // null = looked up, not paired).
79
+ let heartbeatBroker;
80
+ let inflightHeartbeat = null;
81
+ /**
82
+ * Arm heartbeat streaming for this invocation. Call once from the entry point
83
+ * (alongside capturing `startedAt`); without it, progress listeners from
84
+ * `progressReporter()` are inert. Safe to call for any command — skip-listed
85
+ * commands simply never stream.
86
+ */
87
+ export function beginRunReport(args, startedAt) {
88
+ lastHeartbeatAt = 0;
89
+ heartbeatBroker = undefined;
90
+ inflightHeartbeat = null;
91
+ const identity = runIdentity(args, startedAt);
92
+ runContext = identity ? { ...identity, startedAt } : null;
93
+ }
94
+ /**
95
+ * A ProgressListener that streams the run's progress snapshot to the paired
96
+ * hosted app. Verbs compose it with their local renderer:
97
+ *
98
+ * createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
99
+ *
100
+ * Throttled to one POST per ~5s, first no earlier than ~5s into the run,
101
+ * 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
102
+ * must stay generic ("batch 4/12"); CRM field values never leave the machine.
103
+ */
104
+ export function progressReporter(overrides = {}) {
105
+ const now = overrides.now ?? Date.now;
106
+ const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
107
+ const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
108
+ const fetchImpl = overrides.fetchImpl ?? fetch;
109
+ return (_event, snapshot) => {
110
+ try {
111
+ if (!runContext)
112
+ return;
113
+ const at = now();
114
+ if (at - runContext.startedAt < minRunMs)
115
+ return;
116
+ if (at - lastHeartbeatAt < intervalMs)
117
+ return;
118
+ if (heartbeatBroker === undefined) {
119
+ const broker = getCredential("broker");
120
+ heartbeatBroker =
121
+ broker?.baseUrl && broker.accessToken
122
+ ? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
123
+ : null; // opt-in: only when paired
124
+ }
125
+ if (!heartbeatBroker)
126
+ return;
127
+ lastHeartbeatAt = at;
128
+ sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
129
+ }
130
+ catch {
131
+ // Observability is best-effort; never affect the command.
132
+ }
133
+ };
134
+ }
135
+ function sendHeartbeat(fetchImpl, broker, context, snapshot, at) {
136
+ // One heartbeat in flight at a time: a stalled POST is superseded, and the
137
+ // terminal flush aborts any straggler so it can never delay process exit.
138
+ inflightHeartbeat?.abort();
139
+ const controller = new AbortController();
140
+ inflightHeartbeat = controller;
141
+ const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
142
+ timer.unref?.();
143
+ void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
144
+ method: "POST",
145
+ headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
146
+ body: JSON.stringify({
147
+ command: context.command,
148
+ clientRunId: context.clientRunId,
149
+ status: "in_progress",
150
+ startedAt: context.startedAt,
151
+ finishedAt: at,
152
+ durationMs: at - context.startedAt,
153
+ progress: {
154
+ // Stage names + counts only — never CRM field values.
155
+ stage: snapshot.stage,
156
+ stageIndex: snapshot.stageIndex,
157
+ stageCount: snapshot.stageCount,
158
+ itemsDone: snapshot.itemsDone,
159
+ itemsTotal: snapshot.itemsTotal,
160
+ note: snapshot.note,
161
+ heartbeatAt: at,
162
+ },
163
+ }),
164
+ signal: controller.signal,
165
+ })
166
+ .catch(() => {
167
+ // fire-and-forget
168
+ })
169
+ .finally(() => {
170
+ clearTimeout(timer);
171
+ if (inflightHeartbeat === controller)
172
+ inflightHeartbeat = null;
173
+ });
174
+ }
46
175
  /**
47
176
  * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
48
177
  * swallows every error. Call once per process from the entry point.
49
178
  */
50
179
  export async function flushRunReport(args, status, startedAt, error) {
51
- const command = args[0];
52
- if (!command || SKIP_COMMANDS.has(command))
180
+ // The terminal upsert supersedes any streaming heartbeat still in flight.
181
+ inflightHeartbeat?.abort();
182
+ inflightHeartbeat = null;
183
+ // Stable per-invocation identity: the server dedupes on clientRunId, so a
184
+ // retried report, an "in_progress" heartbeat, or a later `backfill runs`
185
+ // replay of local history never duplicates.
186
+ const identity = runIdentity(args, startedAt);
187
+ if (!identity)
53
188
  return;
54
189
  const broker = getCredential("broker");
55
190
  if (!broker?.baseUrl || !broker.accessToken)
56
191
  return; // opt-in: only when paired
57
- const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
58
192
  const finishedAt = Date.now();
59
193
  try {
60
194
  await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
61
195
  method: "POST",
62
196
  headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
63
197
  body: JSON.stringify({
64
- command: `${command}${sub}`,
198
+ command: identity.command,
199
+ clientRunId: identity.clientRunId,
65
200
  status,
66
201
  startedAt,
67
202
  finishedAt,
@@ -40,9 +40,11 @@ export type ScheduleRunRecord = {
40
40
  * recorded for visibility but nothing ran. Reasons: "plan_not_approved"
41
41
  * (scheduled apply against a plan that is not approved — there is no flag
42
42
  * that relaxes this), "not_schedulable" (the stored argv no longer passes
43
- * the allowlist, e.g. after a hand edit of schedules.json).
43
+ * the allowlist, e.g. after a hand edit of schedules.json),
44
+ * "duplicate_firing" (a cron-triggered run already recorded in this minute
45
+ * — launchd can double-trigger the Vixie dom+dow OR corner).
44
46
  */
45
- noopReason?: "plan_not_approved" | "not_schedulable";
47
+ noopReason?: "plan_not_approved" | "not_schedulable" | "duplicate_firing";
46
48
  };
47
49
  export declare function scheduleId(label: string, cron: string, argv: string[], createdAt: string): string;
48
50
  /**
@@ -158,3 +160,79 @@ export declare function renderManagedBlock(profile: string, entries: ScheduleEnt
158
160
  * Idempotent: re-applying the same block yields identical content.
159
161
  */
160
162
  export declare function replaceManagedBlock(existing: string, profile: string, block: string | null): string;
163
+ /**
164
+ * One launchd StartCalendarInterval dict: every present key must match (AND),
165
+ * a missing key is a wildcard, and an array of dicts fires when ANY dict
166
+ * matches. Weekday 0 is Sunday, as in cron.
167
+ */
168
+ export type LaunchdCalendarInterval = {
169
+ Minute?: number;
170
+ Hour?: number;
171
+ Day?: number;
172
+ Weekday?: number;
173
+ Month?: number;
174
+ };
175
+ /**
176
+ * Compile a cron expression to StartCalendarInterval dicts. Full-range fields
177
+ * (`*`, but also spellings like `0-59`) are omitted (launchd wildcard); the
178
+ * rest cross-product, capped so a dense expression cannot render a megabyte
179
+ * plist. Vixie day semantics: when day-of-month AND day-of-week are both
180
+ * restricted, either may match — launchd ANDs keys within one dict, so that
181
+ * becomes the union of a Day dict set and a Weekday dict set. A date matching
182
+ * both sets makes launchd trigger twice in the same minute; the run entry
183
+ * point drops the second via isDuplicateCronFiring.
184
+ */
185
+ export declare function cronToCalendarIntervals(cron: CronExpression, cap?: number): LaunchdCalendarInterval[];
186
+ /**
187
+ * True when a cron-triggered run was already recorded in `firedAt`'s minute.
188
+ * The `schedule run` entry point checks this for trigger:cron so a launchd
189
+ * double-trigger (dom+dow OR corner above) records a duplicate_firing no-op
190
+ * instead of running the command twice. Manual runs are never deduplicated.
191
+ */
192
+ export declare function isDuplicateCronFiring(runs: ScheduleRunRecord[], firedAt: string): boolean;
193
+ /** Reverse-DNS namespace for one profile's agents; install/uninstall own this prefix wholesale. */
194
+ export declare function launchdAgentPrefix(profile: string): string;
195
+ export declare function launchdLabel(profile: string, scheduleEntryId: string): string;
196
+ /**
197
+ * Render one LaunchAgent plist. ProgramArguments is an argv array — launchd
198
+ * execs it directly, no shell, so there is no quoting/injection surface at
199
+ * all (values are XML-escaped for the document, nothing more).
200
+ */
201
+ export declare function renderLaunchdPlist(options: {
202
+ label: string;
203
+ programArguments: string[];
204
+ calendarIntervals: LaunchdCalendarInterval[];
205
+ environment?: Record<string, string>;
206
+ standardOutPath?: string;
207
+ standardErrorPath?: string;
208
+ }): string;
209
+ export type LaunchdIo = {
210
+ /** Plist file names currently in the agents directory. */
211
+ list(): string[];
212
+ write(fileName: string, content: string): void;
213
+ remove(fileName: string): void;
214
+ /** Load one agent plist into the user's gui domain; throws on failure. */
215
+ bootstrap(fileName: string): void;
216
+ /** Best-effort unload by label; a label that is not loaded is not an error. */
217
+ bootout(label: string): void;
218
+ };
219
+ /**
220
+ * The real ~/Library/LaunchAgents + launchctl. Everything above takes a
221
+ * LaunchdIo so tests inject fakes and never touch the user's agents.
222
+ */
223
+ export declare function systemLaunchdIo(agentsDirOverride?: string): LaunchdIo;
224
+ /**
225
+ * Materialize enabled entries as one LaunchAgent each, replacing the
226
+ * profile's com.fullstackgtm.<profile>.* fleet wholesale (stale agents are
227
+ * booted out and deleted; plists outside the prefix are never touched).
228
+ * `cliArgv` is the argv-array analog of the crontab invocation line.
229
+ */
230
+ export declare function installLaunchdAgents(profile: string, entries: ScheduleEntry[], cliArgv: string[], io: LaunchdIo, options?: {
231
+ environment?: Record<string, string>;
232
+ logDir?: string;
233
+ }): {
234
+ installed: string[];
235
+ removed: string[];
236
+ };
237
+ /** Boot out and delete every agent in the profile's prefix; returns the removed labels. */
238
+ export declare function uninstallLaunchdAgents(profile: string, io: LaunchdIo): string[];