@veleads/gtm 0.6.0 → 0.7.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 CHANGED
@@ -5,6 +5,26 @@ All notable changes to `@veleads/gtm`. Each entry is tagged: **no action**, **op
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-10-24
9
+ - **opt-in** `gtm stack up | down [--wipe] | status`: each project repo runs its own stack (Postgres +
10
+ hatchet-lite + gtm worker) on Docker, as compose project `gtm-<project_id>` with its own volumes. The
11
+ worker is the image `ghcr.io/veleads/gtm-worker:<version>` matching the pinned core; it applies
12
+ database migrations when it starts. Connection settings go to `~/.gtm/stacks/<project_id>.env`
13
+ (used by `gtm job`), provider keys to `~/.gtm/stacks/<project_id>.secrets.env`; nothing is written
14
+ into the repo. Ports are configurable (`GTM_PG_PORT`, `GTM_HATCHET_UI_PORT`, `GTM_HATCHET_GRPC_PORT`)
15
+ and bound to localhost only. Releases now also publish the worker image. The gtm-core development
16
+ stack (`pnpm stack:up` + `pnpm worker`) still works as a fallback for `gtm job`.
17
+ - **action** One database per project: each project repo runs its own stack (Postgres + Hatchet +
18
+ workers) and core holds no project state. Rows no longer carry `project_id`; unique keys are now
19
+ per database (`accounts` on `tam_id` + `domain`, `contacts` on `linkedin_url`, …). A new
20
+ single-row `project` table binds the database to one `project_id` on the first job, and workers
21
+ refuse jobs for any other project. The `account`, `contact`, `lead` and `signal` schemas drop
22
+ `project_id`; `project.yaml` and job payloads keep it. DB migration
23
+ `20261025000000_database_per_project` binds an existing single-project database automatically and
24
+ **refuses to run on a database that holds more than one project**: give each project its own
25
+ database (copy its rows, filtered by `project_id`) before upgrading. Point each project's
26
+ `DATABASE_URL` at its own database.
27
+
8
28
  ## [0.6.0] - 2026-10-24
9
29
  - **no action** Layered ICP: `context/icp.yaml` can have an optional `core.firmographics` that every
10
30
  segment shares; segments then hold only what differs, and a segment field **replaces** the same core
package/dist/cli.js CHANGED
@@ -280,7 +280,6 @@ var CampaignConfig = z5.object({
280
280
  // ../schemas/src/account.ts
281
281
  import { z as z6 } from "zod";
282
282
  var Account = z6.object({
283
- project_id: Slug,
284
283
  tam_id: Slug,
285
284
  domain: Domain,
286
285
  name: z6.string().min(1),
@@ -297,7 +296,6 @@ var Account = z6.object({
297
296
  import { z as z7 } from "zod";
298
297
  var EmailStatus = z7.enum(["valid", "catch_all", "invalid", "unknown", "not_found"]);
299
298
  var Contact = z7.object({
300
- project_id: Slug,
301
299
  account_domain: Domain,
302
300
  first_name: z7.string().min(1),
303
301
  last_name: z7.string().min(1),
@@ -314,7 +312,6 @@ var Contact = z7.object({
314
312
  // ../schemas/src/signal.ts
315
313
  import { z as z8 } from "zod";
316
314
  var SignalEvent = z8.object({
317
- project_id: Slug,
318
315
  signal_key: Slug,
319
316
  account_domain: Domain,
320
317
  contact_linkedin_url: z8.string().url().nullable(),
@@ -327,7 +324,6 @@ var SignalEvent = z8.object({
327
324
  // ../schemas/src/lead.ts
328
325
  import { z as z9 } from "zod";
329
326
  var Lead = z9.object({
330
- project_id: Slug,
331
327
  campaign_id: Slug,
332
328
  run_id: z9.uuid(),
333
329
  email: z9.email(),
@@ -467,9 +463,9 @@ function githubToken() {
467
463
  }
468
464
  var login = (handle) => handle.replace(/^@/, "").toLowerCase();
469
465
  var GitHubError = class extends Error {
470
- constructor(status, message) {
466
+ constructor(status2, message) {
471
467
  super(message);
472
- this.status = status;
468
+ this.status = status2;
473
469
  this.name = "GitHubError";
474
470
  }
475
471
  status;
@@ -1299,15 +1295,62 @@ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
1299
1295
  import { homedir } from "os";
1300
1296
  import { join as join9 } from "path";
1301
1297
  var STACK_ENV_FILE = process.env.GTM_STACK_ENV ?? join9(homedir(), ".gtm", "stack.env");
1302
- function loadStackEnv(file = STACK_ENV_FILE) {
1303
- if (!existsSync7(file)) return false;
1298
+ var STACKS_DIR = process.env.GTM_STACKS_DIR ?? join9(homedir(), ".gtm", "stacks");
1299
+ var projectStackEnvFile = (projectId) => join9(STACKS_DIR, `${projectId}.env`);
1300
+ var projectSecretsFile = (projectId) => join9(STACKS_DIR, `${projectId}.secrets.env`);
1301
+ function readEnvFile(file) {
1302
+ const out = {};
1303
+ if (!existsSync7(file)) return out;
1304
1304
  for (const line of readFileSync8(file, "utf8").split("\n")) {
1305
1305
  const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
1306
- if (m && process.env[m[1]] === void 0) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
1306
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
1307
1307
  }
1308
+ return out;
1309
+ }
1310
+ function loadStackEnv(file = STACK_ENV_FILE) {
1311
+ if (!existsSync7(file)) return false;
1312
+ for (const [k, v] of Object.entries(readEnvFile(file))) if (process.env[k] === void 0) process.env[k] = v;
1308
1313
  return true;
1309
1314
  }
1310
1315
 
1316
+ // ../core/src/local-stack.ts
1317
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1318
+ async function waitFor(what, check, seconds = 120) {
1319
+ for (let i = 0; i < seconds; i++) {
1320
+ if (await check()) return;
1321
+ await sleep(1e3);
1322
+ }
1323
+ throw new Error(`timed out waiting for ${what}`);
1324
+ }
1325
+ async function hatchetReady(url) {
1326
+ try {
1327
+ return (await fetch(`${url}/api/ready`)).ok;
1328
+ } catch {
1329
+ return false;
1330
+ }
1331
+ }
1332
+ async function createHatchetToken(exec, name) {
1333
+ let tenant = "";
1334
+ await waitFor("hatchet tenant", () => {
1335
+ try {
1336
+ tenant = exec("postgres", ["psql", "-U", "gtm", "-d", "hatchet", "-tAc", `select id from "Tenant" where slug = 'default'`]);
1337
+ } catch {
1338
+ tenant = "";
1339
+ }
1340
+ return /^[0-9a-f-]{36}$/.test(tenant);
1341
+ });
1342
+ let token = "";
1343
+ await waitFor("hatchet token", () => {
1344
+ try {
1345
+ token = exec("hatchet-lite", ["/hatchet-admin", "token", "create", "--config", "/config", "--tenant-id", tenant, "--name", name]).split("\n").at(-1).trim();
1346
+ } catch {
1347
+ token = "";
1348
+ }
1349
+ return /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
1350
+ }, 60);
1351
+ return token;
1352
+ }
1353
+
1311
1354
  // ../core/src/adapters/mock-account-source.ts
1312
1355
  import { createHash as createHash3 } from "crypto";
1313
1356
 
@@ -1644,9 +1687,10 @@ async function runJob(root2, args2) {
1644
1687
  console.error(input);
1645
1688
  return false;
1646
1689
  }
1690
+ loadStackEnv(projectStackEnvFile(input.ctx.project_id));
1647
1691
  loadStackEnv();
1648
1692
  if (!process.env.HATCHET_CLIENT_TOKEN) {
1649
- console.error(`No Hatchet connection. Local: run \`pnpm stack:up\` and \`pnpm worker\` in gtm-core (writes ${STACK_ENV_FILE}). Deployed: set HATCHET_CLIENT_TOKEN.`);
1693
+ console.error(`No Hatchet connection. Run \`npx gtm stack up\` in this project repo (writes ${projectStackEnvFile(input.ctx.project_id)}). Deployed: set HATCHET_CLIENT_TOKEN.`);
1650
1694
  return false;
1651
1695
  }
1652
1696
  const { HatchetClient } = await import("@hatchet-dev/typescript-sdk/v1");
@@ -1655,7 +1699,7 @@ async function runJob(root2, args2) {
1655
1699
  throw new Error("runs on the gtm worker");
1656
1700
  } });
1657
1701
  console.log(`\u2022 running tam for ${input.segments.length} segment(s) via ${input.source} (budget $${input.budget_usd})`);
1658
- const hint = setTimeout(() => console.log(" \u2026 still waiting. Is the worker running? (`pnpm worker` in gtm-core)"), 2e4);
1702
+ const hint = setTimeout(() => console.log(" \u2026 still waiting. Is the worker running? (`npx gtm stack status`)"), 2e4);
1659
1703
  try {
1660
1704
  const output = await tam.run(input);
1661
1705
  printTam(TamJobResult.parse(output), campaign);
@@ -1695,30 +1739,136 @@ function runNewCampaign(root2, campaign) {
1695
1739
  return true;
1696
1740
  }
1697
1741
 
1698
- // src/commands/status.ts
1699
- import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13 } from "fs";
1742
+ // src/commands/stack.ts
1743
+ import { spawnSync as spawnSync4 } from "child_process";
1744
+ import { chmodSync, existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync13, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
1700
1745
  import { join as join14 } from "path";
1701
1746
  import { parse as parse8 } from "yaml";
1747
+ var WORKER_IMAGE = "ghcr.io/veleads/gtm-worker";
1748
+ var PORTS = { GTM_PG_PORT: 5432, GTM_HATCHET_UI_PORT: 8888, GTM_HATCHET_GRPC_PORT: 7077 };
1749
+ function stackSettings(projectId, env = process.env) {
1750
+ const envFile = projectStackEnvFile(projectId);
1751
+ const saved = readEnvFile(envFile);
1752
+ const ports = Object.fromEntries(
1753
+ Object.keys(PORTS).map((k) => [k, Number(env[k] ?? saved[k] ?? PORTS[k])])
1754
+ );
1755
+ for (const [k, v] of Object.entries(ports)) if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error(`${k} must be a port number, got "${v}"`);
1756
+ return {
1757
+ projectId,
1758
+ composeProject: `gtm-${projectId}`,
1759
+ composeFile: join14(KIT_DIR, "stack", "docker-compose.yml"),
1760
+ envFile,
1761
+ secretsFile: projectSecretsFile(projectId),
1762
+ workerImage: env.GTM_WORKER_IMAGE ?? `${WORKER_IMAGE}:${GTM_VERSION}`,
1763
+ ports
1764
+ };
1765
+ }
1766
+ function composeEnv(s, token = "") {
1767
+ return {
1768
+ ...Object.fromEntries(Object.entries(s.ports).map(([k, v]) => [k, String(v)])),
1769
+ GTM_WORKER_IMAGE: s.workerImage,
1770
+ GTM_SECRETS_FILE: s.secretsFile,
1771
+ HATCHET_CLIENT_TOKEN: token
1772
+ };
1773
+ }
1774
+ function stackEnvText(s, token) {
1775
+ return [
1776
+ `# Written by \`gtm stack up\` for project ${s.projectId}. Local stack only; never commit.`,
1777
+ `DATABASE_URL=postgresql://gtm:gtm@localhost:${s.ports.GTM_PG_PORT}/gtm`,
1778
+ `HATCHET_CLIENT_TOKEN=${token}`,
1779
+ "HATCHET_CLIENT_TLS_STRATEGY=none",
1780
+ ...Object.entries(s.ports).map(([k, v]) => `${k}=${v}`),
1781
+ ""
1782
+ ].join("\n");
1783
+ }
1784
+ function compose(s, args2, opts = {}) {
1785
+ const res = spawnSync4("docker", ["compose", "-f", s.composeFile, "-p", s.composeProject, ...args2], {
1786
+ env: { ...process.env, ...composeEnv(s, opts.token) },
1787
+ encoding: "utf8",
1788
+ stdio: opts.quiet ? "pipe" : ["ignore", "inherit", "inherit"]
1789
+ });
1790
+ if (res.error) throw new Error(`docker is not available (${res.error.message}). Install Docker Desktop or Docker Engine first.`);
1791
+ if (res.status !== 0) throw new Error(`docker compose ${args2.join(" ")} failed${opts.quiet ? `: ${res.stderr || res.stdout}` : ""}`);
1792
+ return (res.stdout ?? "").trim();
1793
+ }
1794
+ async function up(s) {
1795
+ const ui = `http://localhost:${s.ports.GTM_HATCHET_UI_PORT}`;
1796
+ console.log(`\u2022 starting postgres + hatchet-lite (${s.composeProject})`);
1797
+ compose(s, ["up", "-d", "--wait", "postgres"]);
1798
+ compose(s, ["up", "-d", "hatchet-lite"]);
1799
+ await waitFor("hatchet-lite", () => hatchetReady(ui));
1800
+ console.log("\u2022 creating a Hatchet API token");
1801
+ const token = await createHatchetToken((service, args2) => compose(s, ["exec", "-T", service, ...args2], { quiet: true }), `gtm-${s.projectId}-${Date.now()}`);
1802
+ mkdirSync4(STACKS_DIR, { recursive: true, mode: 448 });
1803
+ writeFileSync6(s.envFile, stackEnvText(s, token));
1804
+ chmodSync(s.envFile, 384);
1805
+ console.log(`\u2022 starting the worker (${s.workerImage})`);
1806
+ compose(s, ["up", "-d", "worker"], { token });
1807
+ console.log(`
1808
+ Stack for ${s.projectId} is up.
1809
+ Hatchet UI ${ui} (local login admin@example.com / Admin123!!)
1810
+ Postgres postgresql://gtm:gtm@localhost:${s.ports.GTM_PG_PORT}/gtm (this project's database)
1811
+ Settings ${s.envFile} (used by \`npx gtm job\`)
1812
+ Secrets ${s.secretsFile} (provider API keys for the worker; ${existsSync12(s.secretsFile) ? "found" : "not created yet"})
1813
+ Next: \`npx gtm job tam <campaign>\`.`);
1814
+ }
1815
+ function down(s, wipe) {
1816
+ compose(s, ["down", ...wipe ? ["-v"] : []]);
1817
+ if (wipe) rmSync2(s.envFile, { force: true });
1818
+ console.log(wipe ? `Stack stopped; this project's database, Hatchet state and ${s.envFile} are deleted (secrets file kept).` : "Stack stopped; data kept (`npx gtm stack up` to resume).");
1819
+ }
1820
+ async function status(s) {
1821
+ compose(s, ["ps"]);
1822
+ const ready = await hatchetReady(`http://localhost:${s.ports.GTM_HATCHET_UI_PORT}`);
1823
+ console.log(`hatchet: ${ready ? "ready" : "not ready"} \xB7 settings: ${existsSync12(s.envFile) ? s.envFile : "missing (run `npx gtm stack up`)"} \xB7 worker image: ${s.workerImage}`);
1824
+ }
1825
+ async function runStack(root2, args2, opts = {}) {
1826
+ const [action] = args2;
1827
+ if (!action || !["up", "down", "status"].includes(action)) {
1828
+ console.error("Usage: gtm stack up | down [--wipe] | status");
1829
+ return false;
1830
+ }
1831
+ if (!existsSync12(join14(root2, "project.yaml"))) {
1832
+ console.error("project.yaml not found: run gtm stack inside a project repo.");
1833
+ return false;
1834
+ }
1835
+ try {
1836
+ const project = ProjectFile.parse(parse8(readFileSync13(join14(root2, "project.yaml"), "utf8")));
1837
+ const s = stackSettings(project.project_id);
1838
+ if (action === "up") await up(s);
1839
+ else if (action === "down") down(s, Boolean(opts.wipe));
1840
+ else await status(s);
1841
+ return true;
1842
+ } catch (e) {
1843
+ console.error(`gtm stack ${action}: ${e.message}`);
1844
+ return false;
1845
+ }
1846
+ }
1847
+
1848
+ // src/commands/status.ts
1849
+ import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync14 } from "fs";
1850
+ import { join as join15 } from "path";
1851
+ import { parse as parse9 } from "yaml";
1702
1852
  function readYaml2(file) {
1703
1853
  try {
1704
- return parse8(readFileSync13(file, "utf8"));
1854
+ return parse9(readFileSync14(file, "utf8"));
1705
1855
  } catch {
1706
1856
  return null;
1707
1857
  }
1708
1858
  }
1709
1859
  function campaignStages(root2) {
1710
- return subdirs(join14(root2, "campaigns")).map((id) => ({ id, stage: readYaml2(join14(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
1860
+ return subdirs(join15(root2, "campaigns")).map((id) => ({ id, stage: readYaml2(join15(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
1711
1861
  }
1712
1862
  function runStatus(root2) {
1713
- const project = readYaml2(join14(root2, "project.yaml"));
1714
- const icp = readYaml2(join14(root2, "context", "icp.yaml"));
1863
+ const project = readYaml2(join15(root2, "project.yaml"));
1864
+ const icp = readYaml2(join15(root2, "context", "icp.yaml"));
1715
1865
  console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
1716
1866
  console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
1717
1867
  `);
1718
- const rows = subdirs(join14(root2, "campaigns")).map((id) => {
1719
- const cfg = readYaml2(join14(root2, "campaigns", id, "config.yaml")) ?? {};
1720
- const runsDir = join14(root2, "campaigns", id, "runs");
1721
- const last = existsSync12(runsDir) ? readdirSync3(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
1868
+ const rows = subdirs(join15(root2, "campaigns")).map((id) => {
1869
+ const cfg = readYaml2(join15(root2, "campaigns", id, "config.yaml")) ?? {};
1870
+ const runsDir = join15(root2, "campaigns", id, "runs");
1871
+ const last = existsSync13(runsDir) ? readdirSync3(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
1722
1872
  return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
1723
1873
  });
1724
1874
  if (!rows.length) {
@@ -1735,9 +1885,9 @@ function runStatus(root2) {
1735
1885
  }
1736
1886
 
1737
1887
  // src/commands/upgrade.ts
1738
- import { spawnSync as spawnSync4 } from "child_process";
1739
- import { existsSync as existsSync13, readFileSync as readFileSync14 } from "fs";
1740
- import { join as join15 } from "path";
1888
+ import { spawnSync as spawnSync5 } from "child_process";
1889
+ import { existsSync as existsSync14, readFileSync as readFileSync15 } from "fs";
1890
+ import { join as join16 } from "path";
1741
1891
 
1742
1892
  // src/changelog.ts
1743
1893
  function compareVersions(a, b) {
@@ -1756,17 +1906,17 @@ function changelogBetween(text, from, to) {
1756
1906
 
1757
1907
  // src/commands/upgrade.ts
1758
1908
  function detectPackageManager(root2) {
1759
- if (existsSync13(join15(root2, "pnpm-lock.yaml"))) return "pnpm";
1760
- if (existsSync13(join15(root2, "yarn.lock"))) return "yarn";
1909
+ if (existsSync14(join16(root2, "pnpm-lock.yaml"))) return "pnpm";
1910
+ if (existsSync14(join16(root2, "yarn.lock"))) return "yarn";
1761
1911
  return "npm";
1762
1912
  }
1763
1913
  function installExact(root2, pm, spec) {
1764
- const onPath = (bin) => spawnSync4(bin, ["--version"], { stdio: "ignore" }).status === 0;
1914
+ const onPath = (bin) => spawnSync5(bin, ["--version"], { stdio: "ignore" }).status === 0;
1765
1915
  const [cmd, args2] = pm === "npm" ? ["npm", ["install", "--save-dev", "--save-exact", spec]] : pm === "pnpm" ? onPath("pnpm") ? ["pnpm", ["add", "-D", "--save-exact", spec]] : ["npx", ["-y", "pnpm@10", "add", "-D", "--save-exact", spec]] : onPath("yarn") ? ["yarn", ["add", "-D", "--exact", spec]] : ["npx", ["-y", "yarn@1", "add", "-D", "--exact", spec]];
1766
- return spawnSync4(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
1916
+ return spawnSync5(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
1767
1917
  }
1768
1918
  function gtm(root2, args2) {
1769
- return spawnSync4("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
1919
+ return spawnSync5("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
1770
1920
  }
1771
1921
  var FROZEN_STAGES = ["qa", "launch"];
1772
1922
  function runUpgrade(root2, opts) {
@@ -1781,7 +1931,7 @@ function runUpgrade(root2, opts) {
1781
1931
  console.error("Finish the launch first, or re-run with --force if a GTM engineer approved it.");
1782
1932
  return false;
1783
1933
  }
1784
- const dirty = spawnSync4("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
1934
+ const dirty = spawnSync5("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
1785
1935
  if (dirty.status === 0 && dirty.stdout.trim() && !opts.force) {
1786
1936
  console.error("Refusing to upgrade with uncommitted changes. Commit or stash first (or --force).");
1787
1937
  return false;
@@ -1794,9 +1944,9 @@ function runUpgrade(root2, opts) {
1794
1944
  if (gtm(root2, ["migrate"]) !== 0) return false;
1795
1945
  const valid = gtm(root2, ["validate"]) === 0;
1796
1946
  const after = pinnedVersion(root2) ?? target;
1797
- const changelog = join15(root2, "node_modules", ...PACKAGE_NAME.split("/"), "CHANGELOG.md");
1798
- if (existsSync13(changelog)) {
1799
- const notes = changelogBetween(readFileSync14(changelog, "utf8"), before, after);
1947
+ const changelog = join16(root2, "node_modules", ...PACKAGE_NAME.split("/"), "CHANGELOG.md");
1948
+ if (existsSync14(changelog)) {
1949
+ const notes = changelogBetween(readFileSync15(changelog, "utf8"), before, after);
1800
1950
  console.log(`
1801
1951
  === Changes ${before} \u2192 ${after} ===
1802
1952
  ${notes || "(no changelog entries)"}
@@ -1935,11 +2085,24 @@ var COMMANDS = {
1935
2085
  "tam: builds the TAM for the campaign's segments into the database (needs an approved ICP and",
1936
2086
  "config.yaml \u2192 tam.source; mock-accounts for a local proof) and prints the funnel, cost and run_id.",
1937
2087
  "Validates first, sends the resolved config to the workers via Hatchet, and waits for the result.",
1938
- "Local: `pnpm stack:up` + `pnpm worker` in gtm-core (settings in ~/.gtm/stack.env).",
2088
+ "Needs this project's stack: `npx gtm stack up` (settings in ~/.gtm/stacks/<project_id>.env).",
1939
2089
  "Planned (exit 1, a stop per AGENTS.md): enrich, suppression:import, signals, qualify, qa, push, activate, results."
1940
2090
  ],
1941
2091
  writes: "Rows in Postgres (runs, accounts, costs). No repo files: copy the printed run-log row into the run log.",
1942
2092
  examples: ["npx gtm job tam q1-uk-cfo"]
2093
+ },
2094
+ stack: {
2095
+ usage: "gtm stack up\ngtm stack down [--wipe]\ngtm stack status",
2096
+ summary: "run this project's own stack (Postgres + Hatchet + worker) on Docker",
2097
+ details: [
2098
+ "One stack per project (compose project gtm-<project_id>, its own volumes and database). The worker image",
2099
+ "matches the core version this repo pins and applies database migrations when it starts. Needs Docker.",
2100
+ "Ports: GTM_PG_PORT (5432), GTM_HATCHET_UI_PORT (8888), GTM_HATCHET_GRPC_PORT (7077); set them to run two stacks at once.",
2101
+ "Provider API keys for the worker: ~/.gtm/stacks/<project_id>.secrets.env (never in the repo).",
2102
+ "down keeps the data; down --wipe deletes this project's database and Hatchet state (ask the human first)."
2103
+ ],
2104
+ writes: "Docker containers and volumes, and ~/.gtm/stacks/<project_id>.env. No repo files.",
2105
+ examples: ["npx gtm stack up", "npx gtm stack status", "GTM_PG_PORT=5433 npx gtm stack up"]
1943
2106
  }
1944
2107
  };
1945
2108
  function generalHelp(version) {
@@ -1997,6 +2160,7 @@ var { values, positionals } = parseArgs({
1997
2160
  check: { type: "boolean" },
1998
2161
  "dry-run": { type: "boolean" },
1999
2162
  force: { type: "boolean" },
2163
+ wipe: { type: "boolean" },
2000
2164
  help: { type: "boolean", short: "h" },
2001
2165
  version: { type: "boolean", short: "v" }
2002
2166
  }
@@ -2050,6 +2214,8 @@ async function run() {
2050
2214
  return runUpgrade(root, { version: args[0], force: values.force });
2051
2215
  case "job":
2052
2216
  return runJob(root, args);
2217
+ case "stack":
2218
+ return runStack(root, args, { wipe: values.wipe });
2053
2219
  case void 0:
2054
2220
  return help(void 0);
2055
2221
  case "help":
package/kit/AGENTS.md CHANGED
@@ -26,8 +26,8 @@ Three layers, never mixed:
26
26
  | Layer | Where | Holds | Never holds |
27
27
  |---|---|---|---|
28
28
  | **Brain** | git (this repo) | context, configs, copy, run logs, aggregate reports | PII, lead lists, exports, secrets |
29
- | **Execution** | central workers (deployed from gtm-core, run on Hatchet) | TAM build, enrichment waterfall, verification, scoring, signals, sender push | business decisions |
30
- | **Data** | central Postgres | accounts, contacts, enrichments, signals, suppression, campaign leads, events, runs, costs | anything not scoped by `project_id` + `run_id` |
29
+ | **Execution** | this project's workers (gtm-core code, run on Hatchet) | TAM build, enrichment waterfall, verification, scoring, signals, sender push | business decisions |
30
+ | **Data** | this project's own Postgres (one database per project) | accounts, contacts, enrichments, signals, suppression, campaign leads, events, runs, costs | another project's data; rows without a `run_id` |
31
31
 
32
32
  **The agent orchestrates; code does bulk work.** The agent picks the playbook, edits config, runs a
33
33
  `npx gtm job` command, reads the aggregate output, QAs a small sample, and writes the run log. The
@@ -129,6 +129,7 @@ Rules, no exceptions:
129
129
  - Bounce rate > 3% or spam complaints in results: recommend pausing; the human decides.
130
130
  - Anything that would contact a real person outside a launch that passed all three gates.
131
131
  - You are about to edit `AGENTS.md` or anything in `.gtm/`.
132
+ - You are about to run `npx gtm stack down --wipe`: it deletes this project's database. Only on the human's request.
132
133
 
133
134
  ## 5. Data rules
134
135
 
@@ -140,10 +141,13 @@ Rules, no exceptions:
140
141
  3. QA sample review happens in the DB console or terminal output. Do not paste sampled rows into files
141
142
  or PR descriptions; summarize ("19/20 correct persona").
142
143
  4. **Secrets** live only in `.env` (git-ignored) or the secret manager. Never print, echo, commit or
143
- paste keys. Provider API keys never live in a project repo: they are on the central workers.
144
- 5. Every DB row carries `project_id`, `campaign_id`, `run_id` and timestamps. Every job prints its `run_id`.
144
+ paste keys. Provider API keys never live in a project repo: they are on this project's workers (local stack:
145
+ `~/.gtm/stacks/<project_id>.secrets.env`, written by the human).
146
+ 5. Every DB row carries `campaign_id`, `run_id` and timestamps (the database itself is the project scope).
147
+ Every job prints its `run_id`.
145
148
  6. Every lead field records its source (`field_sources`) so GDPR "where did you get my data?" can be answered.
146
- 7. This project\x27s data is never mixed with another project\x27s: jobs are scoped by `project_id`.
149
+ 7. This project's data is never mixed with another project's: it has its own database, bound to this
150
+ `project_id` on the first job, and jobs for another project are refused.
147
151
 
148
152
  ## 6. Team and concurrency rules
149
153
 
@@ -178,6 +182,8 @@ Run from the repo root. If you cannot run shell commands, give the exact command
178
182
  | `npx gtm sync [--check]` | re-copy core kit into `.gtm/` and `AGENTS.md` | available |
179
183
  | `npx gtm migrate [--dry-run]` | rewrite YAML files to the installed core's schema versions | available |
180
184
  | `npx gtm upgrade [version]` | install a newer core, sync, migrate, validate, show changelog | available |
185
+ | `npx gtm stack up` / `npx gtm stack status` | start / check this project's own stack (Postgres + Hatchet + worker at this core version) on Docker; jobs need it | available |
186
+ | `npx gtm stack down [--wipe]` | stop the stack, data kept; `--wipe` deletes this project's database (only when the human asks) | available |
181
187
  | `npx gtm job tam <campaign>` | build TAM into `accounts` (via the workers), print funnel counts, cost, run_id and a run-log row | available |
182
188
  | `npx gtm job enrich <campaign>` | find people + email waterfall + verification, cost-capped | planned |
183
189
  | `npx gtm job suppression:import <campaign> <file>` | load suppression lists (file stays outside git) | planned |
@@ -203,7 +209,8 @@ Every job prints a `run_id`, counts and cost. Copy those into the run log.
203
209
 
204
210
  - **New project:** create an empty **private** repo (e.g. `<org>/gtm-project-<slug>`), clone it, and in
205
211
  that folder run `npx @veleads/gtm init . --name "<Name>"` (or `init <slug>` to create a new folder).
206
- Commit, push, then stage `00-intake` begins.
212
+ Commit, push, then stage `00-intake` begins. Before the first job (stage 2), start the project's stack
213
+ with `npx gtm stack up` (needs Docker).
207
214
  - **New campaign:** from up-to-date `main`: `git switch -c campaign/<slug>`,
208
215
  `npx gtm new-campaign <slug>`, fill `brief.md`, set `stage`, follow the routing table.
209
216
  - **New SDR:** read this file and `PROJECT.md`, run `npx gtm status`, then section 2 with a GTM engineer watching.
@@ -214,7 +221,7 @@ Every job prints a `run_id`, counts and cost. Copy those into the run log.
214
221
  The PR contains the new `.gtm/`, `AGENTS.md`, lockfile, and any YAML migrations. Read the changelog
215
222
  in the PR body: entries marked **migration** or **action** need a human look.
216
223
  - Upgrades are refused while a campaign is in `qa` or `launch`. Live campaigns (`results`) keep running
217
- on the central workers across upgrades; nothing changes for them mid-flight.
224
+ on this project's workers across upgrades; nothing changes for them mid-flight.
218
225
  - New opt-in capabilities appear as `features` in `project.yaml`; they are off until you enable them.
219
226
  - If `validate` says "run `npx gtm sync`" or "`migrate`", the repo and the installed core disagree:
220
227
  run the command, review the diff, commit it on its own branch.
@@ -2,10 +2,6 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "properties": {
5
- "project_id": {
6
- "type": "string",
7
- "pattern": "^[a-z0-9][a-z0-9-]*$"
8
- },
9
5
  "tam_id": {
10
6
  "type": "string",
11
7
  "pattern": "^[a-z0-9][a-z0-9-]*$"
@@ -90,7 +86,6 @@
90
86
  }
91
87
  },
92
88
  "required": [
93
- "project_id",
94
89
  "tam_id",
95
90
  "domain",
96
91
  "name",
@@ -2,10 +2,6 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "properties": {
5
- "project_id": {
6
- "type": "string",
7
- "pattern": "^[a-z0-9][a-z0-9-]*$"
8
- },
9
5
  "account_domain": {
10
6
  "type": "string",
11
7
  "pattern": "^(?!-)[a-z0-9-]+(\\.[a-z0-9-]+)+$"
@@ -91,7 +87,6 @@
91
87
  }
92
88
  },
93
89
  "required": [
94
- "project_id",
95
90
  "account_domain",
96
91
  "first_name",
97
92
  "last_name",
@@ -2,10 +2,6 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "properties": {
5
- "project_id": {
6
- "type": "string",
7
- "pattern": "^[a-z0-9][a-z0-9-]*$"
8
- },
9
5
  "campaign_id": {
10
6
  "type": "string",
11
7
  "pattern": "^[a-z0-9][a-z0-9-]*$"
@@ -114,7 +110,6 @@
114
110
  }
115
111
  },
116
112
  "required": [
117
- "project_id",
118
113
  "campaign_id",
119
114
  "run_id",
120
115
  "email",
@@ -2,10 +2,6 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "properties": {
5
- "project_id": {
6
- "type": "string",
7
- "pattern": "^[a-z0-9][a-z0-9-]*$"
8
- },
9
5
  "signal_key": {
10
6
  "type": "string",
11
7
  "pattern": "^[a-z0-9][a-z0-9-]*$"
@@ -53,7 +49,6 @@
53
49
  }
54
50
  },
55
51
  "required": [
56
- "project_id",
57
52
  "signal_key",
58
53
  "account_domain",
59
54
  "contact_linkedin_url",
@@ -22,8 +22,9 @@ Know how big the market is, and whether the ICP filters are right, before spendi
22
22
  4. Trigger the job: `npx gtm job tam <campaign>`. It validates, sends segment filters and exclusions
23
23
  from `icp.yaml` to the workers, which page accounts through the adapter, upsert them into
24
24
  `accounts` (re-runs update, never duplicate) and print counts per segment plus a run-log row.
25
- **You do not page through accounts yourself.** If it says there is no Hatchet connection or keeps
26
- waiting for a worker, stop and tell the human (AGENTS.md section 4).
25
+ **You do not page through accounts yourself.** If it says there is no Hatchet connection, run
26
+ `npx gtm stack up` once (needs Docker). If that fails, or the job keeps waiting for a worker
27
+ (`npx gtm stack status`), stop and tell the human (AGENTS.md section 4).
27
28
  5. Funnel counts per segment (printed by the job):
28
29
  - **TAM**: accounts matching filters.
29
30
  - **Reachable**: accounts where at least one target persona can be found (from the job's
@@ -36,7 +37,7 @@ Know how big the market is, and whether the ICP filters are right, before spendi
36
37
  8. Write `tam/<tam_id>.md` from `.gtm/templates/outputs/tam-summary.md` and the run log.
37
38
 
38
39
  ## Outputs
39
- - Rows in `accounts` (Postgres), tagged `project_id`, `tam_id`, `run_id`.
40
+ - Rows in `accounts` (this project's Postgres), tagged `tam_id`, `segment_id`, `run_id`.
40
41
  - `tam/<tam_id>.md` with funnel table and sanity check. Run log.
41
42
 
42
43
  ## Checklist
@@ -0,0 +1,66 @@
1
+ # One project's stack: Postgres (this project's data + Hatchet's state), hatchet-lite (job engine) and
2
+ # the gtm worker at the core version this project repo pins. Driven by `npx gtm stack up|down|status`,
3
+ # which sets the compose project name (gtm-<project_id>), the ports and the worker image.
4
+ # Data stays in this project's Docker volumes; nothing here is written into the repo.
5
+ services:
6
+ postgres:
7
+ image: postgres:16.10
8
+ command: postgres -c max_connections=200
9
+ environment:
10
+ POSTGRES_USER: gtm
11
+ POSTGRES_PASSWORD: gtm
12
+ POSTGRES_DB: gtm
13
+ # Local only: reachable from this machine (QA samples in a DB console), not from the network.
14
+ ports: ["127.0.0.1:${GTM_PG_PORT:-5432}:5432"]
15
+ volumes:
16
+ - pgdata:/var/lib/postgresql/data
17
+ - ./init:/docker-entrypoint-initdb.d:ro
18
+ healthcheck:
19
+ test: ["CMD-SHELL", "pg_isready -U gtm -d gtm"]
20
+ interval: 2s
21
+ timeout: 5s
22
+ retries: 30
23
+
24
+ hatchet-lite:
25
+ image: ghcr.io/hatchet-dev/hatchet/hatchet-lite:v0.110.5
26
+ depends_on:
27
+ postgres:
28
+ condition: service_healthy
29
+ ports:
30
+ - "127.0.0.1:${GTM_HATCHET_UI_PORT:-8888}:8888"
31
+ - "127.0.0.1:${GTM_HATCHET_GRPC_PORT:-7077}:7077"
32
+ environment:
33
+ DATABASE_URL: postgresql://gtm:gtm@postgres:5432/hatchet?sslmode=disable
34
+ SERVER_AUTH_COOKIE_DOMAIN: localhost
35
+ SERVER_AUTH_COOKIE_INSECURE: "t"
36
+ SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
37
+ SERVER_GRPC_INSECURE: "t"
38
+ SERVER_GRPC_BROADCAST_ADDRESS: localhost:${GTM_HATCHET_GRPC_PORT:-7077}
39
+ SERVER_GRPC_PORT: "7077"
40
+ SERVER_URL: http://localhost:${GTM_HATCHET_UI_PORT:-8888}
41
+ SERVER_AUTH_SET_EMAIL_VERIFIED: "t"
42
+ SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: localhost:7077
43
+ volumes:
44
+ - hatchet_config:/config
45
+
46
+ worker:
47
+ image: ${GTM_WORKER_IMAGE:-ghcr.io/veleads/gtm-worker:latest}
48
+ depends_on:
49
+ postgres:
50
+ condition: service_healthy
51
+ hatchet-lite:
52
+ condition: service_started
53
+ restart: unless-stopped
54
+ environment:
55
+ DATABASE_URL: postgresql://gtm:gtm@postgres:5432/gtm
56
+ HATCHET_CLIENT_TOKEN: ${HATCHET_CLIENT_TOKEN:-}
57
+ HATCHET_CLIENT_HOST_PORT: hatchet-lite:7077
58
+ HATCHET_CLIENT_TLS_STRATEGY: none
59
+ # Provider API keys: ~/.gtm/stacks/<project_id>.secrets.env, outside the repo. Optional.
60
+ env_file:
61
+ - path: ${GTM_SECRETS_FILE:-/dev/null}
62
+ required: false
63
+
64
+ volumes:
65
+ pgdata:
66
+ hatchet_config:
@@ -0,0 +1,2 @@
1
+ -- hatchet-lite keeps its own state in a separate database on the same Postgres instance.
2
+ CREATE DATABASE hatchet;
@@ -10,6 +10,7 @@ npm install # or: pnpm install / yarn install
10
10
  cp .env.example .env
11
11
  npx gtm status
12
12
  npx gtm validate
13
+ npx gtm stack up # this project's own stack (needs Docker); jobs run on it
13
14
  ```
14
15
 
15
16
  ## What lives where
@@ -1,5 +1,5 @@
1
- # Copy to .env (never commit). Provider API keys do NOT belong here: they live on the central worker.
1
+ # Copy to .env (never commit). Provider API keys do NOT belong here: they go to this project's worker
2
+ # (local stack: ~/.gtm/stacks/<project_id>.secrets.env). `npx gtm stack up` writes the connection
3
+ # settings to ~/.gtm/stacks/<project_id>.env; set HATCHET_CLIENT_TOKEN here only for a hosted stack.
2
4
  GTM_USER=jdoe
3
- # Phase 2: trigger jobs on the shared Hatchet, read-only DB access for QA samples
4
5
  HATCHET_CLIENT_TOKEN=
5
- DATABASE_URL_READONLY=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veleads/gtm",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "gtm-brain core: process kit (AGENTS.md, skills, templates, schemas) and the gtm CLI for client repos",
5
5
  "keywords": [
6
6
  "gtm",
@@ -39,8 +39,8 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsup": "^8.5.0",
42
- "@gtm/schemas": "0.0.0",
43
- "@gtm/core": "0.0.0"
42
+ "@gtm/core": "0.0.0",
43
+ "@gtm/schemas": "0.0.0"
44
44
  },
45
45
  "homepage": "https://github.com/Veleads/gtm-core#readme",
46
46
  "bugs": {