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.
- package/CHANGELOG.md +109 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +22 -10
- package/dist/connectors/hubspot.d.ts +7 -0
- package/dist/connectors/hubspot.js +236 -9
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +7 -0
- package/dist/connectors/salesforce.js +28 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +18 -1
- package/docs/api.md +2 -1
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +29 -9
- package/src/connectors/hubspot.ts +261 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +34 -2
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +20 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/progress.ts
ADDED
|
@@ -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
|
-
|
|
88
|
-
|
|
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:
|
|
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
|
|
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";
|
package/docs/dx-punch-list.md
DELETED
|
@@ -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.
|