@veleads/gtm 0.4.0 → 0.6.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/dist/cli.js CHANGED
@@ -16,6 +16,8 @@ var IsoDate = z.iso.date();
16
16
  var IsoDateTime = z.iso.datetime({ offset: true });
17
17
  var Domain = z.string().regex(/^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/, "bare lowercase domain, e.g. acme.com");
18
18
  var Tier = z.enum(["A", "B", "C"]);
19
+ var GitHubHandle = z.string().regex(/^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, "GitHub handle like @username");
20
+ var isGitHubHandle = (value) => !!value && GitHubHandle.safeParse(value).success;
19
21
  var Stage = z.enum([
20
22
  "intake",
21
23
  "icp",
@@ -33,7 +35,8 @@ var Approval = z.object({
33
35
  approved_by: z.string().min(1).nullable(),
34
36
  approved_at: IsoDate.nullable(),
35
37
  notes: z.string().optional(),
36
- content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand.")
38
+ content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand."),
39
+ approved_via: z.string().url().optional().describe("Pull request whose review is the approval (GitHub mode). Written by `gtm approve --pr`.")
37
40
  }).refine(
38
41
  (a) => a.status !== "approved" || a.approved_by !== null && a.approved_at !== null,
39
42
  "approved requires approved_by and approved_at"
@@ -63,26 +66,43 @@ var ProjectFile = z2.object({
63
66
  status: z2.enum(["onboarding", "active", "paused", "closed"]),
64
67
  started_at: IsoDate,
65
68
  owners: z2.object({
66
- gtm_engineer: z2.string().min(1),
67
- sdrs: z2.array(z2.string().min(1)),
68
- approver: z2.string().min(1).describe("Person who signs off ICP and copy for this project")
69
+ gtm_engineer: z2.string().min(1).describe("GitHub handle (@user) or name"),
70
+ sdrs: z2.array(z2.string().min(1)).describe("GitHub handles (@user) or names"),
71
+ approver: z2.string().min(1).describe("Signs off ICP and copy. A GitHub handle (@user) makes approvals PR reviews enforced by GitHub; a name records them by name")
69
72
  }),
70
73
  regions: z2.array(z2.string().min(2)).min(1).describe("Target regions, e.g. EU, UK, US"),
71
- features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES")
74
+ features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES"),
75
+ tracker: z2.object({ kind: z2.enum(["github", "none"]) }).default({ kind: "none" }).describe("Where `gtm track` mirrors stages and gates. Git files stay the source of truth")
72
76
  });
73
77
 
74
78
  // ../schemas/src/icp.ts
75
79
  import { z as z3 } from "zod";
80
+ var FirmographicFilters = z3.object({
81
+ industries: z3.array(z3.string()).optional(),
82
+ employee_count: IntRange.optional(),
83
+ revenue_usd: IntRange.optional(),
84
+ hq_countries: z3.array(z3.string().length(2)).optional().describe("ISO 3166-1 alpha-2"),
85
+ technologies_any: z3.array(z3.string()).optional(),
86
+ technologies_none: z3.array(z3.string()).optional(),
87
+ keywords_any: z3.array(z3.string()).optional(),
88
+ keywords_none: z3.array(z3.string()).optional()
89
+ });
76
90
  var Firmographics = z3.object({
77
91
  industries: z3.array(z3.string()).default([]),
78
92
  employee_count: IntRange.optional(),
79
93
  revenue_usd: IntRange.optional(),
80
- hq_countries: z3.array(z3.string().length(2)).default([]).describe("ISO 3166-1 alpha-2"),
94
+ hq_countries: z3.array(z3.string().length(2)).default([]),
81
95
  technologies_any: z3.array(z3.string()).default([]),
82
96
  technologies_none: z3.array(z3.string()).default([]),
83
97
  keywords_any: z3.array(z3.string()).default([]),
84
98
  keywords_none: z3.array(z3.string()).default([])
85
99
  });
100
+ var IcpSegmentSpec = z3.object({
101
+ id: Slug,
102
+ description: z3.string().min(1),
103
+ firmographics: FirmographicFilters.default({}),
104
+ personas: z3.array(Slug).min(1).optional().describe("Restrict to these persona ids; default: all personas")
105
+ });
86
106
  var IcpSegment = z3.object({
87
107
  id: Slug,
88
108
  description: z3.string().min(1),
@@ -96,11 +116,22 @@ var IcpPersona = z3.object({
96
116
  seniorities: z3.array(z3.enum(["c_level", "vp", "head", "director", "manager", "senior", "entry"])).default([]),
97
117
  departments: z3.array(z3.string()).default([])
98
118
  });
119
+ var FILTER_KEYS = Object.keys(FirmographicFilters.shape);
120
+ function mergeFilters(core, segment) {
121
+ const merged = {};
122
+ for (const key of FILTER_KEYS) {
123
+ const value = segment?.[key] !== void 0 ? segment[key] : core?.[key];
124
+ if (value !== void 0) merged[key] = value;
125
+ }
126
+ return Firmographics.parse(merged);
127
+ }
128
+ var hasAnyFilter = (f) => f.industries.length + f.hq_countries.length + f.technologies_any.length + f.keywords_any.length > 0 || !!f.employee_count || !!f.revenue_usd;
99
129
  var IcpFile = z3.object({
100
130
  version: z3.literal(FILE_VERSIONS.icp),
101
131
  project_id: Slug,
102
132
  approval: Approval,
103
- segments: z3.array(IcpSegment).min(1),
133
+ core: z3.object({ firmographics: FirmographicFilters.default({}) }).optional().describe("Profile every segment shares. Segments only add or replace what differs"),
134
+ segments: z3.array(IcpSegmentSpec).min(1),
104
135
  personas: z3.array(IcpPersona).min(1),
105
136
  exclusions: z3.object({
106
137
  domains: z3.array(Domain).default([]),
@@ -113,7 +144,39 @@ var IcpFile = z3.object({
113
144
  const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
114
145
  if (dupes.length) ctx.addIssue({ code: "custom", path: [key], message: `duplicate ids: ${dupes.join(", ")}` });
115
146
  }
147
+ const personaIds = new Set(icp.personas.map((p) => p.id));
148
+ icp.segments.forEach((s, i) => {
149
+ for (const p of s.personas ?? []) {
150
+ if (!personaIds.has(p)) ctx.addIssue({ code: "custom", path: ["segments", i, "personas"], message: `unknown persona "${p}"` });
151
+ }
152
+ let resolved;
153
+ try {
154
+ resolved = mergeFilters(icp.core?.firmographics, s.firmographics);
155
+ } catch {
156
+ return;
157
+ }
158
+ if (!hasAnyFilter(resolved)) {
159
+ ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics"], message: `segment "${s.id}" has no filters (core + segment): it would match every company` });
160
+ }
161
+ const range = resolved.employee_count;
162
+ if (range?.min !== void 0 && range.max !== void 0 && range.min > range.max) {
163
+ ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics", "employee_count"], message: `segment "${s.id}" resolves to min > max` });
164
+ }
165
+ });
116
166
  });
167
+ function resolveIcp(icp) {
168
+ const allPersonas = icp.personas.map((p) => p.id);
169
+ return {
170
+ exclusions: icp.exclusions,
171
+ segments: icp.segments.map((s) => ({
172
+ id: s.id,
173
+ description: s.description,
174
+ firmographics: mergeFilters(icp.core?.firmographics, s.firmographics),
175
+ personas: s.personas ?? allPersonas,
176
+ overrides: FILTER_KEYS.filter((k) => s.firmographics?.[k] !== void 0)
177
+ }))
178
+ };
179
+ }
117
180
 
118
181
  // ../schemas/src/signals-config.ts
119
182
  import { z as z4 } from "zod";
@@ -148,6 +211,10 @@ var Enrichment = z5.object({
148
211
  max_contacts_per_account: z5.number().int().min(1).max(10),
149
212
  cache_ttl_days: z5.number().int().positive()
150
213
  });
214
+ var Tam = z5.object({
215
+ source: Slug.describe("Account source adapter (mock-accounts for local proofs; real providers in .gtm/providers/)"),
216
+ max_accounts_per_segment: z5.number().int().positive().max(1e5).default(500)
217
+ });
151
218
  var Budget = z5.object({
152
219
  currency: z5.literal("USD"),
153
220
  max_total: z5.number().positive().describe("Hard cap for this campaign; jobs stop when reached"),
@@ -194,6 +261,7 @@ var CampaignConfig = z5.object({
194
261
  owner: z5.string().min(1),
195
262
  stage: Stage.describe("The stage currently being worked on"),
196
263
  tam_id: Slug,
264
+ tam: Tam.optional().describe("How `gtm job tam` builds the TAM"),
197
265
  segments: z5.array(Slug).min(1).describe("Segment ids from icp.yaml"),
198
266
  personas: z5.array(Slug).min(1).describe("Persona ids from icp.yaml"),
199
267
  enrichment: Enrichment,
@@ -343,10 +411,155 @@ function copyContentHash(markdown) {
343
411
  return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`;
344
412
  }
345
413
 
414
+ // ../schemas/src/jobs.ts
415
+ import { z as z10 } from "zod";
416
+ var JOB_NAMES = { tam: "gtm-tam" };
417
+ var JobContext = z10.object({
418
+ project_id: Slug,
419
+ campaign_id: Slug,
420
+ triggered_by: z10.string().min(1),
421
+ core_version: z10.string().min(1),
422
+ git_sha: z10.string().nullable(),
423
+ config_hash: z10.string().min(1)
424
+ });
425
+ var TamJobInput = z10.object({
426
+ ctx: JobContext,
427
+ tam_id: Slug,
428
+ source: Slug.describe("Account source adapter, e.g. mock-accounts"),
429
+ max_accounts_per_segment: z10.number().int().positive().max(1e5),
430
+ segments: z10.array(IcpSegment).min(1),
431
+ exclusions: z10.object({ domains: z10.array(Domain), industries: z10.array(z10.string()), hq_countries: z10.array(z10.string()) }),
432
+ budget_usd: z10.number().nonnegative()
433
+ });
434
+ var TamJobResult = z10.object({
435
+ run_id: z10.uuid(),
436
+ segments: z10.array(z10.object({
437
+ segment_id: Slug,
438
+ found: z10.number().int(),
439
+ inserted: z10.number().int(),
440
+ updated: z10.number().int(),
441
+ excluded: z10.number().int(),
442
+ reachable: z10.number().int(),
443
+ active: z10.number().int()
444
+ })),
445
+ totals: z10.object({ tam: z10.number().int(), reachable: z10.number().int(), active: z10.number().int() }),
446
+ cost_usd: z10.number().nonnegative()
447
+ });
448
+
449
+ // src/commands/approve.ts
450
+ import { parse } from "yaml";
451
+
452
+ // src/github.ts
453
+ import { spawnSync } from "child_process";
454
+ function repoFromGit(root2) {
455
+ const res = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root2, encoding: "utf8" });
456
+ return res.status === 0 ? parseRepoUrl(res.stdout.trim()) : null;
457
+ }
458
+ function parseRepoUrl(url) {
459
+ const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
460
+ return m ? { owner: m[1], repo: m[2] } : null;
461
+ }
462
+ function githubToken() {
463
+ const env = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
464
+ if (env) return env;
465
+ const gh = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
466
+ return gh.status === 0 && gh.stdout.trim() ? gh.stdout.trim() : null;
467
+ }
468
+ var login = (handle) => handle.replace(/^@/, "").toLowerCase();
469
+ var GitHubError = class extends Error {
470
+ constructor(status, message) {
471
+ super(message);
472
+ this.status = status;
473
+ this.name = "GitHubError";
474
+ }
475
+ status;
476
+ };
477
+ var GitHubApi = class _GitHubApi {
478
+ constructor(repo, token, fetchImpl = fetch) {
479
+ this.repo = repo;
480
+ this.token = token;
481
+ this.fetchImpl = fetchImpl;
482
+ }
483
+ repo;
484
+ token;
485
+ fetchImpl;
486
+ /** Resolves repo + token from the working copy and environment; null with a reason if unavailable. */
487
+ static fromRepo(root2, fetchImpl = fetch) {
488
+ const repo = repoFromGit(root2);
489
+ if (!repo) return "no GitHub remote: `git remote get-url origin` must point to github.com";
490
+ const token = githubToken();
491
+ if (!token) return "no GitHub token: run `gh auth login`, or set GH_TOKEN / GITHUB_TOKEN";
492
+ return new _GitHubApi(repo, token, fetchImpl);
493
+ }
494
+ /** Path relative to /repos/{owner}/{repo} when it starts with "repo:", else absolute (e.g. /user). */
495
+ async request(method, path, body) {
496
+ const url = path.startsWith("repo:") ? `https://api.github.com/repos/${this.repo.owner}/${this.repo.repo}${path.slice(5)}` : `https://api.github.com${path}`;
497
+ const res = await this.fetchImpl(url, {
498
+ method,
499
+ headers: {
500
+ Accept: "application/vnd.github+json",
501
+ Authorization: `Bearer ${this.token}`,
502
+ "X-GitHub-Api-Version": "2022-11-28",
503
+ ...body ? { "Content-Type": "application/json" } : {}
504
+ },
505
+ body: body ? JSON.stringify(body) : void 0
506
+ });
507
+ if (!res.ok) throw new GitHubError(res.status, `GitHub ${method} ${path.replace(/^repo:/, "")}: ${res.status} ${await res.text()}`);
508
+ return res.status === 204 ? void 0 : await res.json();
509
+ }
510
+ /** Blob URL for a repo file on the default branch, for links in issue bodies. */
511
+ fileUrl(path) {
512
+ return `https://github.com/${this.repo.owner}/${this.repo.repo}/blob/HEAD/${path}`;
513
+ }
514
+ };
515
+
516
+ // src/github-approval.ts
517
+ function prNumber(ref) {
518
+ const m = ref.match(/(?:^#?|\/pull\/)(\d+)\/?$/);
519
+ return m ? Number(m[1]) : null;
520
+ }
521
+ function latestStateBy(reviews, approver) {
522
+ return reviews.filter((r) => r.user && login(r.user.login) === login(approver) && r.state !== "COMMENTED").at(-1);
523
+ }
524
+ async function verifyPrApproval(api, ref, approver, path) {
525
+ const n = prNumber(ref);
526
+ if (!n) return `--pr must be a pull request number or URL (got "${ref}")`;
527
+ const pr = await api.request("GET", `repo:/pulls/${n}`);
528
+ const files = await api.request("GET", `repo:/pulls/${n}/files?per_page=100`);
529
+ if (!files.some((f) => f.filename === path)) return `PR #${n} does not change ${path}`;
530
+ if (login(pr.user.login) === login(approver)) {
531
+ return `PR #${n} was opened by ${approver}, and GitHub doesn't let authors approve their own PRs. The PR must be opened by someone else (e.g. the GTM engineer or the agent's account) and approved by ${approver}.`;
532
+ }
533
+ const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
534
+ const review = latestStateBy(reviews, approver);
535
+ if (!review || review.state !== "APPROVED") return `${approver} has not approved PR #${n} (latest review: ${review?.state ?? "none"})`;
536
+ if (review.commit_id !== pr.head.sha) return `${approver} approved an older commit of PR #${n}; the PR changed since. Ask for a fresh approval.`;
537
+ const file = await api.request("GET", `repo:/contents/${path}?ref=${pr.head.sha}`);
538
+ return {
539
+ prUrl: pr.html_url,
540
+ reviewer: `@${review.user.login}`,
541
+ approvedAt: review.submitted_at.slice(0, 10),
542
+ approvedContent: Buffer.from(file.content, file.encoding).toString("utf8")
543
+ };
544
+ }
545
+ async function verifyRecordedApproval(api, prUrl, approver, path, pinnedHash, hashOf) {
546
+ const n = prNumber(prUrl);
547
+ if (!n) return `approved_via is not a pull request URL: ${prUrl}`;
548
+ if (!prUrl.includes(`/${api.repo.owner}/${api.repo.repo}/pull/`)) return `approved_via points to another repository: ${prUrl}`;
549
+ const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
550
+ const review = latestStateBy(reviews, approver);
551
+ if (review?.state !== "APPROVED") return `${approver}'s latest review on PR #${n} is ${review?.state ?? "missing"}, not APPROVED`;
552
+ const file = await api.request("GET", `repo:/contents/${path}?ref=${review.commit_id}`);
553
+ const reviewed = hashOf(Buffer.from(file.content, file.encoding).toString("utf8"));
554
+ return reviewed === pinnedHash ? true : `${path} at the commit ${approver} approved (${review.commit_id.slice(0, 7)}) has content ${reviewed}, but ${pinnedHash ?? "no hash"} is pinned`;
555
+ }
556
+
346
557
  // src/commands/approve.ts
347
558
  var USAGE = `Usage:
348
- gtm approve icp --by "<Name (role)>" [--notes "<what was approved>"] [--date YYYY-MM-DD]
349
- gtm approve copy <campaign> --by "<Name (role)>" [--notes "<what was approved>"] [--date YYYY-MM-DD]`;
559
+ gtm approve icp --pr <number> [--notes ".."] (approver is a GitHub @handle)
560
+ gtm approve copy <campaign> --pr <number> [--notes ".."]
561
+ gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD] (approver is a name)
562
+ gtm approve copy <campaign> --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]`;
350
563
  function load(file) {
351
564
  if (!existsSync(file)) {
352
565
  console.error(`${file} not found: run this from the root of a project repo`);
@@ -362,6 +575,7 @@ function load(file) {
362
575
  function pin(file, doc, path, schema, hash, opts) {
363
576
  const approval = { status: "approved", approved_by: opts.by, approved_at: opts.date, content_hash: hash };
364
577
  if (opts.notes) approval.notes = opts.notes;
578
+ if (opts.via) approval.approved_via = opts.via;
365
579
  const candidate = doc.toJS();
366
580
  let target = candidate;
367
581
  for (const key of path.slice(0, -1)) target = target[key] ??= {};
@@ -373,94 +587,469 @@ function pin(file, doc, path, schema, hash, opts) {
373
587
  return false;
374
588
  }
375
589
  for (const [key, value] of Object.entries(approval)) doc.setIn([...path, key], value);
590
+ if (!opts.via) doc.deleteIn([...path, "approved_via"]);
376
591
  writeFileSync(file, doc.toString({ flowCollectionPadding: false, lineWidth: 0 }));
377
592
  return true;
378
593
  }
379
- function runApprove(root2, args2, opts) {
594
+ async function runApprove(root2, args2, opts) {
380
595
  const [gate, campaign] = args2;
381
596
  if (gate !== "icp" && gate !== "copy") {
382
597
  console.error(USAGE);
383
598
  if (gate) console.error(`The "${gate}" gate is recorded by hand in config.yaml \u2192 gates (AGENTS.md section 4).`);
384
599
  return false;
385
600
  }
386
- const by = opts.by?.trim();
387
- if (!by) {
388
- console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
601
+ if (gate === "copy" && !campaign) {
602
+ console.error(USAGE);
389
603
  return false;
390
604
  }
391
- const date = opts.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
392
- if (!IsoDate.safeParse(date).success) {
393
- console.error(`--date must be YYYY-MM-DD (got "${date}")`);
605
+ const approver = (existsSync(join(root2, "project.yaml")) ? parse(readFileSync(join(root2, "project.yaml"), "utf8")) : null)?.owners?.approver;
606
+ const githubMode = isGitHubHandle(approver);
607
+ const contentPath = gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/copy.md`;
608
+ const contentFile = join(root2, contentPath);
609
+ if (!existsSync(contentFile)) {
610
+ console.error(`${contentPath} not found`);
394
611
  return false;
395
612
  }
396
- if (gate === "icp") {
397
- const file2 = join(root2, "context", "icp.yaml");
398
- const doc2 = load(file2);
399
- if (!doc2) return false;
400
- const hash2 = icpContentHash(doc2.toJS());
401
- if (!pin(file2, doc2, ["approval"], IcpFile, hash2, { ...opts, by, date })) return false;
402
- console.log(`ICP approved by ${by} on ${date} (content ${hash2}).`);
403
- console.log("Commit context/icp.yaml together with the run log that quotes the approval.");
404
- return true;
613
+ const local = readFileSync(contentFile, "utf8");
614
+ if (gate === "copy" && !local.split("\n").some((l) => l.trim() && !/^\s*(>|#)/.test(l))) {
615
+ console.error(`${contentPath} has no copy yet`);
616
+ return false;
405
617
  }
406
- if (!campaign) {
407
- console.error(USAGE);
618
+ const hashOf = (text) => gate === "icp" ? icpContentHash(parse(text)) : copyContentHash(text);
619
+ let by, date, via;
620
+ if (githubMode) {
621
+ if (!opts.pr) {
622
+ console.error(`The approver is ${approver} (GitHub mode): the approval is their APPROVED review on a pull request.`);
623
+ console.error(`Open a PR with ${contentPath}, have ${approver} approve it on GitHub, then run: npx gtm approve ${gate}${campaign ? ` ${campaign}` : ""} --pr <number>`);
624
+ return false;
625
+ }
626
+ const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
627
+ if (typeof api === "string") {
628
+ console.error(`Cannot verify on GitHub: ${api}`);
629
+ return false;
630
+ }
631
+ const verified = await verifyPrApproval(api, opts.pr, approver, contentPath);
632
+ if (typeof verified === "string") {
633
+ console.error(verified);
634
+ return false;
635
+ }
636
+ if (hashOf(verified.approvedContent) !== hashOf(local)) {
637
+ console.error(`Your local ${contentPath} differs from the version ${approver} approved on the PR. Check out / pull the PR's latest commit, then run this again.`);
638
+ return false;
639
+ }
640
+ by = verified.reviewer;
641
+ date = verified.approvedAt;
642
+ via = verified.prUrl;
643
+ } else {
644
+ if (opts.pr) {
645
+ console.error(`--pr needs a GitHub approver: set project.yaml \u2192 owners.approver to their @handle, or record with --by.`);
646
+ return false;
647
+ }
648
+ by = opts.by?.trim() ?? "";
649
+ if (!by) {
650
+ console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
651
+ return false;
652
+ }
653
+ date = opts.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
654
+ if (!IsoDate.safeParse(date).success) {
655
+ console.error(`--date must be YYYY-MM-DD (got "${date}")`);
656
+ return false;
657
+ }
658
+ }
659
+ const hash = hashOf(local);
660
+ const file = gate === "icp" ? contentFile : join(root2, "campaigns", campaign, "config.yaml");
661
+ const doc = load(file);
662
+ if (!doc) return false;
663
+ const path = gate === "icp" ? ["approval"] : ["gates", "copy"];
664
+ if (!pin(file, doc, path, gate === "icp" ? IcpFile : CampaignConfig, hash, { ...opts, by, date, via })) return false;
665
+ const what = gate === "icp" ? "ICP" : `Copy for ${campaign}`;
666
+ console.log(`${what} approved by ${by} on ${date}${via ? ` via ${via}` : ""} (content ${hash}).`);
667
+ console.log(`Commit ${gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml and copy.md`} together with the run log that quotes the approval${via ? "" : ""}.`);
668
+ if (via) console.log(`Then close the tracker item: npx gtm track gate-done ${gate}${campaign ? ` ${campaign}` : ""}`);
669
+ return true;
670
+ }
671
+
672
+ // src/commands/icp.ts
673
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
674
+ import { join as join2 } from "path";
675
+ import { parse as parse2 } from "yaml";
676
+ var LABELS = {
677
+ industries: "industries",
678
+ employee_count: "employees",
679
+ revenue_usd: "revenue (USD)",
680
+ hq_countries: "HQ countries",
681
+ technologies_any: "tech (any of)",
682
+ technologies_none: "tech (none of)",
683
+ keywords_any: "keywords (any)",
684
+ keywords_none: "keywords (none)"
685
+ };
686
+ function show(value) {
687
+ if (Array.isArray(value)) return value.length ? value.join(", ") : null;
688
+ if (value && typeof value === "object") {
689
+ const r = value;
690
+ return r.min !== void 0 && r.max !== void 0 ? `${r.min}\u2013${r.max}` : r.min !== void 0 ? `\u2265 ${r.min}` : r.max !== void 0 ? `\u2264 ${r.max}` : null;
691
+ }
692
+ return null;
693
+ }
694
+ function runIcp(root2) {
695
+ const file = join2(root2, "context", "icp.yaml");
696
+ if (!existsSync2(file)) {
697
+ console.error("context/icp.yaml not found: run this from the root of a project repo");
408
698
  return false;
409
699
  }
410
- const dir = join(root2, "campaigns", campaign);
411
- const copyFile = join(dir, "copy.md");
412
- if (!existsSync(copyFile)) {
413
- console.error(`campaigns/${campaign}/copy.md not found`);
700
+ const parsed = IcpFile.safeParse(parse2(readFileSync2(file, "utf8")));
701
+ if (!parsed.success) {
702
+ console.error("context/icp.yaml is not valid (run `npx gtm validate` for details):");
703
+ for (const i of parsed.error.issues) console.error(` \u2717 ${i.path.join(".") || "(root)"}: ${i.message}`);
414
704
  return false;
415
705
  }
416
- const copy = readFileSync(copyFile, "utf8");
417
- if (!copy.split("\n").some((l) => l.trim() && !/^\s*(>|#)/.test(l))) {
418
- console.error(`campaigns/${campaign}/copy.md has no copy yet`);
706
+ const icp = parsed.data;
707
+ const resolved = resolveIcp(icp);
708
+ console.log(`ICP for ${icp.project_id} \xB7 approval: ${icp.approval.status}${icp.approval.approved_by ? ` by ${icp.approval.approved_by}` : ""}`);
709
+ const core = icp.core?.firmographics ?? {};
710
+ const coreLines = Object.keys(LABELS).map((k) => [LABELS[k], show(core[k])]).filter((line) => line[1] !== null);
711
+ console.log(`
712
+ Core (every segment): ${coreLines.length ? "" : "none (each segment defines its own filters)"}`);
713
+ for (const [label, v] of coreLines) console.log(` ${label.padEnd(16)}${v}`);
714
+ for (const s of resolved.segments) {
715
+ console.log(`
716
+ Segment ${s.id}: ${s.description}`);
717
+ for (const k of Object.keys(LABELS)) {
718
+ const v = show(s.firmographics[k]);
719
+ if (v) console.log(` ${LABELS[k].padEnd(16)}${v}${s.overrides.includes(k) ? core[k] !== void 0 ? " \u2190 segment (replaces core)" : " \u2190 segment" : ""}`);
720
+ }
721
+ console.log(` ${"personas".padEnd(16)}${s.personas.join(", ")}${s.personas.length < icp.personas.length ? " \u2190 restricted" : ""}`);
722
+ }
723
+ const x = resolved.exclusions;
724
+ const ex = [x.domains.length && `${x.domains.length} domain(s)`, x.industries.length && `industries: ${x.industries.join(", ")}`, x.hq_countries.length && `countries: ${x.hq_countries.join(", ")}`].filter(Boolean);
725
+ console.log(`
726
+ Exclusions (all segments): ${ex.length ? ex.join(" \xB7 ") : "none"}`);
727
+ return true;
728
+ }
729
+
730
+ // src/commands/github.ts
731
+ var LABELS2 = [
732
+ { name: "gtm", color: "5319e7", description: "Managed by gtm track" },
733
+ { name: "campaign", color: "1d76db", description: "gtm campaign tracker" },
734
+ { name: "gate", color: "d93f0b", description: "gtm approval gate (hard stop)" },
735
+ ...["icp", "copy", "launch"].map((g) => ({ name: `gate:${g}`, color: "d93f0b", description: `gtm ${g} gate` })),
736
+ ...Stage.options.map((s) => ({ name: `stage:${s}`, color: "c5def5", description: `gtm stage ${s}` }))
737
+ ];
738
+ var RULESET_NAME = "gtm: approvals";
739
+ function rulesetBody() {
740
+ return {
741
+ name: RULESET_NAME,
742
+ target: "branch",
743
+ enforcement: "active",
744
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
745
+ rules: [
746
+ { type: "deletion" },
747
+ { type: "non_fast_forward" },
748
+ {
749
+ type: "pull_request",
750
+ parameters: {
751
+ required_approving_review_count: 0,
752
+ require_code_owner_review: true,
753
+ // Off on purpose: `gtm approve --pr` commits the approval record to the PR after the review.
754
+ // Content after approval is guarded by the pinned hash, re-checked by `gtm validate
755
+ // --verify-github` against the exact reviewed commit (a required CI check).
756
+ dismiss_stale_reviews_on_push: false,
757
+ require_last_push_approval: false,
758
+ required_review_thread_resolution: false
759
+ }
760
+ },
761
+ { type: "required_status_checks", parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: "check" }] } }
762
+ ]
763
+ };
764
+ }
765
+ async function runGithub(root2, args2, opts) {
766
+ if (args2[0] !== "setup") {
767
+ console.error("Usage: gtm github setup [--apply]");
419
768
  return false;
420
769
  }
421
- const file = join(dir, "config.yaml");
422
- const doc = load(file);
423
- if (!doc) return false;
424
- const hash = copyContentHash(copy);
425
- if (!pin(file, doc, ["gates", "copy"], CampaignConfig, hash, { ...opts, by, date })) return false;
426
- console.log(`Copy for ${campaign} approved by ${by} on ${date} (content ${hash}).`);
427
- console.log(`Commit campaigns/${campaign}/config.yaml and copy.md together with the run log that quotes the approval.`);
770
+ const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
771
+ if (typeof api === "string") {
772
+ console.error(api);
773
+ return false;
774
+ }
775
+ const repo = `${api.repo.owner}/${api.repo.repo}`;
776
+ console.log(`GitHub setup for ${repo}${opts.apply ? "" : " (plan only: re-run with --apply)"}:`);
777
+ console.log(` \u2022 labels: ${LABELS2.map((l) => l.name).join(", ")}`);
778
+ console.log(` \u2022 ruleset "${RULESET_NAME}" on the default branch: PR required, review from CODEOWNERS required, CI job "check" must pass, no force-push or deletion`);
779
+ console.log(" \u2022 CODEOWNERS is maintained by `npx gtm sync` from project.yaml \u2192 owners.approver");
780
+ if (!opts.apply) return true;
781
+ const existing = new Set((await api.request("GET", "repo:/labels?per_page=100")).map((l) => l.name));
782
+ for (const label of LABELS2) {
783
+ if (existing.has(label.name)) continue;
784
+ await api.request("POST", "repo:/labels", label);
785
+ console.log(` + label ${label.name}`);
786
+ }
787
+ try {
788
+ const rulesets = await api.request("GET", "repo:/rulesets");
789
+ const found = rulesets.find((r) => r.name === RULESET_NAME);
790
+ if (found) await api.request("PUT", `repo:/rulesets/${found.id}`, rulesetBody());
791
+ else await api.request("POST", "repo:/rulesets", rulesetBody());
792
+ console.log(` ${found ? "~ updated" : "+ created"} ruleset "${RULESET_NAME}"`);
793
+ } catch (e) {
794
+ if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) {
795
+ console.error(` \u2717 could not create the ruleset (${e.status}). Rulesets on private repos need a paid GitHub plan (Team/Pro), and the token needs admin rights.`);
796
+ console.error(" Without it GitHub still requests the approver's review via CODEOWNERS, and `gtm validate --verify-github` in CI still verifies every approval.");
797
+ return false;
798
+ }
799
+ throw e;
800
+ }
801
+ console.log("Done. In CI, `npx gtm validate --verify-github` checks each recorded approval against GitHub.");
802
+ return true;
803
+ }
804
+
805
+ // src/commands/track.ts
806
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
807
+ import { join as join3 } from "path";
808
+ import { parse as parse3 } from "yaml";
809
+
810
+ // src/tracker/github.ts
811
+ var marker = (key) => `<!-- gtm-key: ${key} -->`;
812
+ var BASE_LABEL = "gtm";
813
+ var GitHubTracker = class {
814
+ constructor(api) {
815
+ this.api = api;
816
+ }
817
+ api;
818
+ kind = "github";
819
+ async find(key) {
820
+ for (let page = 1; page <= 10; page++) {
821
+ const issues = await this.api.request("GET", `repo:/issues?labels=${BASE_LABEL}&state=all&per_page=100&page=${page}`);
822
+ const hit = issues.find((i) => !i.pull_request && i.body?.includes(marker(key)));
823
+ if (hit) return hit;
824
+ if (issues.length < 100) return null;
825
+ }
826
+ return null;
827
+ }
828
+ async upsert(item) {
829
+ const body = `${item.body}
830
+
831
+ ${marker(item.key)}`;
832
+ const wanted = [.../* @__PURE__ */ new Set([BASE_LABEL, ...item.labels])];
833
+ const existing = await this.find(item.key);
834
+ if (!existing) {
835
+ const created = await this.api.request("POST", "repo:/issues", { title: item.title, body, labels: wanted, assignees: item.assignees ?? [] });
836
+ return { key: item.key, url: created.html_url, created: true };
837
+ }
838
+ const exclusive = item.exclusiveLabelPrefixes ?? [];
839
+ const kept = existing.labels.map((l) => l.name).filter((n) => !exclusive.some((p) => n.startsWith(p)) || wanted.includes(n));
840
+ await this.api.request("PATCH", `repo:/issues/${existing.number}`, {
841
+ title: item.title,
842
+ body,
843
+ state: "open",
844
+ labels: [.../* @__PURE__ */ new Set([...kept, ...wanted])],
845
+ ...item.assignees ? { assignees: item.assignees } : {}
846
+ });
847
+ return { key: item.key, url: existing.html_url, created: false };
848
+ }
849
+ async comment(key, text) {
850
+ const issue = await this.find(key);
851
+ if (!issue) throw new Error(`no tracked issue for "${key}"`);
852
+ await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
853
+ }
854
+ async close(key, text) {
855
+ const issue = await this.find(key);
856
+ if (!issue) throw new Error(`no tracked issue for "${key}"`);
857
+ if (text) await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
858
+ await this.api.request("PATCH", `repo:/issues/${issue.number}`, { state: "closed", state_reason: "completed" });
859
+ }
860
+ };
861
+
862
+ // src/tracker/none.ts
863
+ var noneTracker = {
864
+ kind: "none",
865
+ async upsert(item) {
866
+ return { key: item.key, url: "", created: false };
867
+ },
868
+ async comment() {
869
+ },
870
+ async close() {
871
+ }
872
+ };
873
+
874
+ // src/tracker/index.ts
875
+ function createTracker(kind, root2, fetchImpl = fetch) {
876
+ if (kind === "none") return noneTracker;
877
+ if (kind === "github") {
878
+ const api = GitHubApi.fromRepo(root2, fetchImpl);
879
+ return typeof api === "string" ? `tracker github: ${api}` : new GitHubTracker(api);
880
+ }
881
+ return `unknown tracker kind "${kind}" (supported: github, none)`;
882
+ }
883
+
884
+ // src/commands/track.ts
885
+ var GATES = ["icp", "copy", "launch"];
886
+ var USAGE2 = `Usage:
887
+ gtm track stage <campaign>
888
+ gtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]
889
+ gtm track gate-done <icp|copy|launch> [campaign]`;
890
+ function readYaml(file) {
891
+ return existsSync3(file) ? parse3(readFileSync3(file, "utf8")) : null;
892
+ }
893
+ var handles = (...values2) => values2.filter(isGitHubHandle).map((h) => h.slice(1));
894
+ async function upsert(ctx, item) {
895
+ try {
896
+ const res = await ctx.tracker.upsert(item);
897
+ return res.url;
898
+ } catch (e) {
899
+ if (e instanceof GitHubError && e.status === 422 && item.assignees?.length) {
900
+ console.log(` ! could not assign ${item.assignees.map((a) => "@" + a).join(", ")} (not a collaborator?): created unassigned`);
901
+ return (await ctx.tracker.upsert({ ...item, assignees: [] })).url;
902
+ }
903
+ throw e;
904
+ }
905
+ }
906
+ function gateKey(gate, campaign) {
907
+ return gate === "icp" ? "gate:icp" : `gate:${gate}:${campaign}`;
908
+ }
909
+ function gateFile(gate, campaign) {
910
+ return gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml`;
911
+ }
912
+ function gateApproval(root2, gate, campaign) {
913
+ const doc = readYaml(join3(root2, gateFile(gate, campaign)));
914
+ return gate === "icp" ? doc?.approval : doc?.gates?.[gate];
915
+ }
916
+ async function trackStage(ctx, campaign) {
917
+ const cfg = campaign ? readYaml(join3(ctx.root, "campaigns", campaign, "config.yaml")) : null;
918
+ if (!campaign || !cfg) {
919
+ console.error(campaign ? `campaigns/${campaign}/config.yaml not found` : USAGE2);
920
+ return false;
921
+ }
922
+ const current = Stage.safeParse(cfg.stage).success ? cfg.stage : null;
923
+ if (!current) {
924
+ console.error(`campaigns/${campaign}/config.yaml has no valid stage`);
925
+ return false;
926
+ }
927
+ const idx = Stage.options.indexOf(current);
928
+ const checklist = Stage.options.map((s, i) => `- [${i < idx ? "x" : " "}] ${s}${s === current ? " \u2190 current" : ""}`).join("\n");
929
+ const url = await upsert(ctx, {
930
+ key: `campaign:${campaign}`,
931
+ title: `Campaign: ${campaign}`,
932
+ body: `**Stage: \`${current}\`** (source of truth: \`campaigns/${campaign}/config.yaml\`)
933
+
934
+ ${checklist}
935
+
936
+ Brief: \`campaigns/${campaign}/brief.md\`. Counts and decisions only in this issue: never lead data.`,
937
+ labels: ["campaign", `stage:${current}`],
938
+ exclusiveLabelPrefixes: ["stage:"],
939
+ assignees: handles(cfg.owner, ctx.project?.owners?.gtm_engineer)
940
+ });
941
+ if (url) await ctx.tracker.comment(`campaign:${campaign}`, `Stage \u2192 \`${current}\``);
942
+ console.log(url ? `tracked campaign ${campaign} at stage ${current}: ${url}` : `tracker none: skipped (campaign ${campaign} at ${current})`);
428
943
  return true;
429
944
  }
945
+ var GATE_TEXT = {
946
+ icp: { title: "ICP sign-off", review: "segments + filters, personas, exclusions, signals with weights, expected TAM range (`context/icp.md`, `context/icp.yaml`, `context/signals.yaml`)" },
947
+ copy: { title: "Copy sign-off", review: "every step of every variant in `copy.md`, rendered with one realistic fictional example" },
948
+ launch: { title: "Launch go/no-go", review: "`qa.md` result, leads by tier, daily volume, spend so far, paused sender campaign" }
949
+ };
950
+ async function gateOpen(ctx, gate, campaign, pr) {
951
+ if (gate !== "icp" && !campaign) {
952
+ console.error(USAGE2);
953
+ return false;
954
+ }
955
+ const owners = ctx.project?.owners ?? {};
956
+ const approver = gate === "launch" ? owners.gtm_engineer : owners.approver;
957
+ const how = isGitHubHandle(approver) ? `**${approver}**: review and approve the pull request${pr ? ` #${pr}` : ""} on GitHub. Then the agent records it with \`npx gtm approve ${gate === "icp" ? "icp" : `${gate} ${campaign}`} --pr <number>\`.` : `Approver **${approver ?? "(not set)"}**: reply with an explicit approval; it is recorded with \`npx gtm approve ... --by "<name (role)>"\`.`;
958
+ const key = gateKey(gate, campaign);
959
+ const url = await upsert(ctx, {
960
+ key,
961
+ title: `${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`,
962
+ body: `**Hard stop.** Nothing moves past this gate until it is approved.
963
+
964
+ Review: ${GATE_TEXT[gate].review}.
965
+
966
+ ${how}
967
+
968
+ Source of truth: \`${gateFile(gate, campaign)}\`.`,
969
+ labels: ["gate", `gate:${gate}`],
970
+ assignees: handles(approver)
971
+ });
972
+ console.log(url ? `opened ${GATE_TEXT[gate].title}: ${url}` : `tracker none: skipped (${key})`);
973
+ return true;
974
+ }
975
+ async function gateDone(ctx, gate, campaign) {
976
+ if (gate !== "icp" && !campaign) {
977
+ console.error(USAGE2);
978
+ return false;
979
+ }
980
+ const approval = gateApproval(ctx.root, gate, campaign);
981
+ if (approval?.status !== "approved") {
982
+ console.error(`${gateFile(gate, campaign)} says this gate is "${approval?.status ?? "missing"}": record the approval first; the tracker only mirrors it.`);
983
+ return false;
984
+ }
985
+ const via = approval.approved_via ? ` via ${approval.approved_via}` : "";
986
+ if (ctx.tracker.kind === "none") {
987
+ console.log(`tracker none: skipped (${gateKey(gate, campaign)})`);
988
+ return true;
989
+ }
990
+ await ctx.tracker.close(gateKey(gate, campaign), `\u2705 Approved by ${approval.approved_by} on ${approval.approved_at}${via}.`);
991
+ console.log(`closed ${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`);
992
+ return true;
993
+ }
994
+ async function runTrack(root2, args2, opts = {}) {
995
+ const [action, a1, a2] = args2;
996
+ const project = readYaml(join3(root2, "project.yaml"));
997
+ if (!project) {
998
+ console.error("project.yaml not found: run this from the root of a project repo");
999
+ return false;
1000
+ }
1001
+ const tracker = createTracker(project.tracker?.kind ?? "none", root2, opts.fetchImpl);
1002
+ if (typeof tracker === "string") {
1003
+ console.error(tracker);
1004
+ return false;
1005
+ }
1006
+ const ctx = { root: root2, project, tracker };
1007
+ try {
1008
+ if (action === "stage") return await trackStage(ctx, a1);
1009
+ if ((action === "gate-open" || action === "gate-done") && GATES.includes(a1)) {
1010
+ return action === "gate-open" ? await gateOpen(ctx, a1, a2, opts.pr) : await gateDone(ctx, a1, a2);
1011
+ }
1012
+ console.error(USAGE2);
1013
+ return false;
1014
+ } catch (e) {
1015
+ console.error(`tracker error: ${e.message}`);
1016
+ return false;
1017
+ }
1018
+ }
430
1019
 
431
1020
  // src/commands/init.ts
432
- import { spawnSync } from "child_process";
433
- import { appendFileSync, existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
434
- import { basename, join as join6, relative as relative2, resolve } from "path";
1021
+ import { spawnSync as spawnSync2 } from "child_process";
1022
+ import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
1023
+ import { basename, join as join8, relative as relative2, resolve } from "path";
435
1024
 
436
1025
  // src/paths.ts
437
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
438
- import { dirname, join as join2 } from "path";
1026
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
1027
+ import { dirname, join as join4 } from "path";
439
1028
  import { fileURLToPath } from "url";
440
- var pkgRoot = join2(dirname(fileURLToPath(import.meta.url)), "..");
1029
+ var pkgRoot = join4(dirname(fileURLToPath(import.meta.url)), "..");
441
1030
  var PACKAGE_NAME = "@veleads/gtm";
442
- var GTM_VERSION = JSON.parse(readFileSync2(join2(pkgRoot, "package.json"), "utf8")).version;
1031
+ var GTM_VERSION = JSON.parse(readFileSync4(join4(pkgRoot, "package.json"), "utf8")).version;
443
1032
  function firstExisting(candidates) {
444
- return candidates.find((p) => existsSync2(p)) ?? null;
1033
+ return candidates.find((p) => existsSync4(p)) ?? null;
445
1034
  }
446
- var kit = firstExisting([join2(pkgRoot, "kit"), join2(pkgRoot, "..", "..", "kit")]);
1035
+ var kit = firstExisting([join4(pkgRoot, "kit"), join4(pkgRoot, "..", "..", "kit")]);
447
1036
  if (!kit) throw new Error("gtm: kit/ not found next to the package; reinstall @veleads/gtm");
448
1037
  var KIT_DIR = kit;
449
- var CHANGELOG_FILE = firstExisting([join2(pkgRoot, "CHANGELOG.md"), join2(pkgRoot, "..", "..", "CHANGELOG.md")]);
1038
+ var CHANGELOG_FILE = firstExisting([join4(pkgRoot, "CHANGELOG.md"), join4(pkgRoot, "..", "..", "CHANGELOG.md")]);
450
1039
 
451
1040
  // src/template.ts
452
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
453
- import { dirname as dirname2, join as join4 } from "path";
1041
+ import { mkdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1042
+ import { dirname as dirname2, join as join6 } from "path";
454
1043
 
455
1044
  // src/fsutil.ts
456
1045
  import { readdirSync, statSync } from "fs";
457
- import { join as join3, relative } from "path";
1046
+ import { join as join5, relative } from "path";
458
1047
  function walk(dir, skip = []) {
459
1048
  const out = [];
460
1049
  const visit = (d) => {
461
1050
  for (const name of readdirSync(d)) {
462
1051
  if (skip.includes(name)) continue;
463
- const p = join3(d, name);
1052
+ const p = join5(d, name);
464
1053
  if (statSync(p).isDirectory()) visit(p);
465
1054
  else out.push(relative(dir, p));
466
1055
  }
@@ -470,7 +1059,7 @@ function walk(dir, skip = []) {
470
1059
  }
471
1060
  function subdirs(dir) {
472
1061
  try {
473
- return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(join3(dir, n)).isDirectory()).sort();
1062
+ return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(join5(dir, n)).isDirectory()).sort();
474
1063
  } catch {
475
1064
  return [];
476
1065
  }
@@ -507,7 +1096,7 @@ function destName(rel) {
507
1096
  }
508
1097
  function planTemplate(src, vars, rename = destName) {
509
1098
  return walk(src).map((rel) => {
510
- const raw = readFileSync3(join4(src, rel));
1099
+ const raw = readFileSync5(join6(src, rel));
511
1100
  if (!/\.(md|ya?ml|json|txt|example)$|gitignore$/.test(rel)) return { rel: rename(rel), content: raw };
512
1101
  let text = raw.toString("utf8");
513
1102
  for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`__${k}__`, v);
@@ -515,7 +1104,7 @@ function planTemplate(src, vars, rename = destName) {
515
1104
  });
516
1105
  }
517
1106
  function writePlanned(root2, file) {
518
- const out = join4(root2, file.rel);
1107
+ const out = join6(root2, file.rel);
519
1108
  mkdirSync(dirname2(out), { recursive: true });
520
1109
  writeFileSync2(out, file.content);
521
1110
  }
@@ -524,25 +1113,41 @@ function copyTemplate(src, dest, vars, opts = {}) {
524
1113
  }
525
1114
 
526
1115
  // src/commands/sync.ts
527
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "fs";
528
- import { dirname as dirname3, join as join5 } from "path";
529
- var SHIPPED_DIRS = ["skills", "templates", "providers", "schemas"];
1116
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
1117
+ import { parse as parse4 } from "yaml";
1118
+ import { dirname as dirname3, join as join7 } from "path";
1119
+ var SHIPPED_DIRS = ["skills", "templates", "providers", "schemas", "guides"];
1120
+ var CODEOWNERS = join7(".github", "CODEOWNERS");
1121
+ var BLOCK_START = "# BEGIN gtm: generated by `gtm sync` from project.yaml \u2192 owners.approver. Edit owners there, not here.";
1122
+ var BLOCK_END = "# END gtm";
1123
+ function expectedCodeowners(root2) {
1124
+ const projectFile = join7(root2, "project.yaml");
1125
+ const approver = existsSync5(projectFile) ? parse4(readFileSync6(projectFile, "utf8"))?.owners?.approver : void 0;
1126
+ const path = join7(root2, CODEOWNERS);
1127
+ const current = existsSync5(path) ? readFileSync6(path, "utf8") : "";
1128
+ const outside = current.replace(new RegExp(`\\n?${BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${BLOCK_END}\\n?`), "\n").trim();
1129
+ const block = isGitHubHandle(approver) ? [BLOCK_START, `/context/icp.yaml ${approver}`, `/context/signals.yaml ${approver}`, `/campaigns/*/copy.md ${approver}`, BLOCK_END].join("\n") : "";
1130
+ const next = [outside, block].filter(Boolean).join("\n\n");
1131
+ if (!next) return current ? "" : null;
1132
+ return `${next}
1133
+ `;
1134
+ }
530
1135
  function agentsMd() {
531
1136
  const header = `<!-- GENERATED by \`gtm sync\` from ${PACKAGE_NAME} v${GTM_VERSION}. Do not edit: it is overwritten on upgrade.
532
1137
  Project-specific rules go in PROJECT.md. Process changes go to the Veleads/gtm-core repo. -->
533
1138
 
534
1139
  `;
535
- return header + readFileSync4(join5(KIT_DIR, "AGENTS.md"), "utf8").replaceAll("{{GTM_VERSION}}", GTM_VERSION);
1140
+ return header + readFileSync6(join7(KIT_DIR, "AGENTS.md"), "utf8").replaceAll("{{GTM_VERSION}}", GTM_VERSION);
536
1141
  }
537
1142
  function expectedFiles() {
538
1143
  const files = /* @__PURE__ */ new Map();
539
1144
  for (const dir of SHIPPED_DIRS) {
540
- for (const rel of walk(join5(KIT_DIR, dir))) files.set(join5(".gtm", dir, rel), readFileSync4(join5(KIT_DIR, dir, rel), "utf8"));
1145
+ for (const rel of walk(join7(KIT_DIR, dir))) files.set(join7(".gtm", dir, rel), readFileSync6(join7(KIT_DIR, dir, rel), "utf8"));
541
1146
  }
542
- files.set(join5(".gtm", "VERSION"), `${GTM_VERSION}
1147
+ files.set(join7(".gtm", "VERSION"), `${GTM_VERSION}
543
1148
  `);
544
1149
  files.set(
545
- join5(".gtm", "README.md"),
1150
+ join7(".gtm", "README.md"),
546
1151
  `# .gtm/
547
1152
 
548
1153
  Copied from ${PACKAGE_NAME} v${GTM_VERSION} by \`gtm sync\`. Read-only: CI fails if it differs from the
@@ -556,13 +1161,16 @@ function checkSync(root2) {
556
1161
  const expected = expectedFiles();
557
1162
  const diffs = [];
558
1163
  for (const [rel, content] of expected) {
559
- const p = join5(root2, rel);
560
- if (!existsSync3(p)) diffs.push(`missing ${rel}`);
561
- else if (readFileSync4(p, "utf8") !== content) diffs.push(`changed ${rel}`);
1164
+ const p = join7(root2, rel);
1165
+ if (!existsSync5(p)) diffs.push(`missing ${rel}`);
1166
+ else if (readFileSync6(p, "utf8") !== content) diffs.push(`changed ${rel}`);
562
1167
  }
563
- if (existsSync3(join5(root2, ".gtm"))) {
564
- for (const rel of walk(join5(root2, ".gtm"))) if (!expected.has(join5(".gtm", rel))) diffs.push(`extra ${join5(".gtm", rel)}`);
1168
+ if (existsSync5(join7(root2, ".gtm"))) {
1169
+ for (const rel of walk(join7(root2, ".gtm"))) if (!expected.has(join7(".gtm", rel))) diffs.push(`extra ${join7(".gtm", rel)}`);
565
1170
  }
1171
+ const owners = expectedCodeowners(root2);
1172
+ const ownersPath = join7(root2, CODEOWNERS);
1173
+ if (owners !== null && (existsSync5(ownersPath) ? readFileSync6(ownersPath, "utf8") : "") !== owners) diffs.push(`changed ${CODEOWNERS} (gtm block)`);
566
1174
  return diffs;
567
1175
  }
568
1176
  function runSync(root2, opts = {}) {
@@ -576,18 +1184,23 @@ function runSync(root2, opts = {}) {
576
1184
  for (const d of diffs) console.log(` ${d}`);
577
1185
  return false;
578
1186
  }
579
- rmSync(join5(root2, ".gtm"), { recursive: true, force: true });
1187
+ rmSync(join7(root2, ".gtm"), { recursive: true, force: true });
580
1188
  for (const [rel, content] of expectedFiles()) {
581
- mkdirSync2(dirname3(join5(root2, rel)), { recursive: true });
582
- writeFileSync3(join5(root2, rel), content);
1189
+ mkdirSync2(dirname3(join7(root2, rel)), { recursive: true });
1190
+ writeFileSync3(join7(root2, rel), content);
1191
+ }
1192
+ if (!existsSync5(join7(root2, "CLAUDE.md"))) writeFileSync3(join7(root2, "CLAUDE.md"), "@AGENTS.md\n");
1193
+ const owners = expectedCodeowners(root2);
1194
+ if (owners !== null) {
1195
+ mkdirSync2(join7(root2, ".github"), { recursive: true });
1196
+ writeFileSync3(join7(root2, CODEOWNERS), owners);
583
1197
  }
584
- if (!existsSync3(join5(root2, "CLAUDE.md"))) writeFileSync3(join5(root2, "CLAUDE.md"), "@AGENTS.md\n");
585
1198
  console.log(`synced .gtm/ and AGENTS.md to ${PACKAGE_NAME} v${GTM_VERSION}`);
586
1199
  return true;
587
1200
  }
588
1201
 
589
1202
  // src/commands/init.ts
590
- var USAGE2 = `Usage:
1203
+ var USAGE3 = `Usage:
591
1204
  gtm init <project-slug> --name "<Project Name>" [--dir <path>] create ./gtm-project-<slug> (or <path>)
592
1205
  gtm init . --name "<Project Name>" [--slug <project-slug>] set up the current folder
593
1206
  Slugs: lowercase letters, digits and hyphens.`;
@@ -604,26 +1217,26 @@ function runInit(target, opts) {
604
1217
  const root2 = resolve(opts.cwd, here ? opts.dir ?? "." : opts.dir ?? `gtm-project-${target ?? ""}`);
605
1218
  const slug = target && target !== "." ? target : opts.slug ?? slugFromFolder(root2);
606
1219
  if (!opts.name || !slug || !Slug.safeParse(slug).success) {
607
- console.error(USAGE2);
1220
+ console.error(USAGE3);
608
1221
  if (!slug && here) console.error(`Couldn't derive a slug from the folder name "${basename(root2)}": pass one with --slug`);
609
1222
  else if (slug && !Slug.safeParse(slug).success) console.error(`"${slug}" is not a valid slug: pass one with --slug`);
610
1223
  return false;
611
1224
  }
612
- if (existsSync4(join6(root2, "project.yaml")) || existsSync4(join6(root2, "client.yaml"))) {
1225
+ if (existsSync6(join8(root2, "project.yaml")) || existsSync6(join8(root2, "client.yaml"))) {
613
1226
  console.error(`${root2} is already a gtm project. Use \`npx gtm sync\` or \`npx gtm upgrade\` instead.`);
614
1227
  return false;
615
1228
  }
616
1229
  const vars = { PROJECT_ID: slug, PROJECT_NAME: opts.name, TODAY: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), GTM_VERSION };
617
- const tpl = join6(KIT_DIR, "templates");
1230
+ const tpl = join8(KIT_DIR, "templates");
618
1231
  const planned = [
619
- ...planTemplate(join6(tpl, "repo"), vars),
620
- ...planTemplate(join6(tpl, "project"), vars, (rel) => rel === "project.yaml" ? rel : join6("context", rel)),
1232
+ ...planTemplate(join8(tpl, "repo"), vars),
1233
+ ...planTemplate(join8(tpl, "project"), vars, (rel) => rel === "project.yaml" ? rel : join8("context", rel)),
621
1234
  { rel: "campaigns/.gitkeep", content: "" },
622
1235
  { rel: "tam/.gitkeep", content: "" }
623
1236
  ];
624
1237
  const coreOwned = [...expectedFiles().keys()];
625
- const conflicts = [...planned.map((f) => f.rel), ...coreOwned].filter((rel) => existsSync4(join6(root2, rel)) && !MERGEABLE.has(rel));
626
- if (existsSync4(join6(root2, ".gtm"))) conflicts.unshift(".gtm/");
1238
+ const conflicts = [...planned.map((f) => f.rel), ...coreOwned].filter((rel) => existsSync6(join8(root2, rel)) && !MERGEABLE.has(rel));
1239
+ if (existsSync6(join8(root2, ".gtm"))) conflicts.unshift(".gtm/");
627
1240
  if (conflicts.length) {
628
1241
  console.error(`Cannot set up ${root2}: these files already exist and belong to gtm. Nothing was written.`);
629
1242
  for (const c of [...new Set(conflicts)].slice(0, 15)) console.error(` \u2717 ${c}`);
@@ -632,13 +1245,13 @@ function runInit(target, opts) {
632
1245
  }
633
1246
  const notes = [];
634
1247
  for (const file of planned) {
635
- const path = join6(root2, file.rel);
636
- if (!existsSync4(path)) {
1248
+ const path = join8(root2, file.rel);
1249
+ if (!existsSync6(path)) {
637
1250
  writePlanned(root2, file);
638
1251
  continue;
639
1252
  }
640
1253
  if (file.rel === ".gitignore") {
641
- const add = missingIgnoreLines(readFileSync5(path, "utf8"), file.content.toString());
1254
+ const add = missingIgnoreLines(readFileSync7(path, "utf8"), file.content.toString());
642
1255
  if (add.length) {
643
1256
  appendFileSync(path, `
644
1257
  # gtm-brain
@@ -650,15 +1263,15 @@ ${add.join("\n")}
650
1263
  notes.push("README.md: kept yours (see .gtm/templates/repo/README.md for the gtm version)");
651
1264
  }
652
1265
  }
653
- const claude = join6(root2, "CLAUDE.md");
654
- const hadClaude = existsSync4(claude);
1266
+ const claude = join8(root2, "CLAUDE.md");
1267
+ const hadClaude = existsSync6(claude);
655
1268
  runSync(root2);
656
- if (hadClaude && !readFileSync5(claude, "utf8").includes("@AGENTS.md")) {
1269
+ if (hadClaude && !readFileSync7(claude, "utf8").includes("@AGENTS.md")) {
657
1270
  appendFileSync(claude, "\n@AGENTS.md\n");
658
1271
  notes.push("CLAUDE.md: added @AGENTS.md so Claude Code loads the operating manual");
659
1272
  }
660
- const inRepo = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root2, encoding: "utf8" }).stdout?.trim() === "true";
661
- const gitNote = inRepo ? "existing git repository kept" : spawnSync("git", ["init", "-q", "-b", "main"], { cwd: root2 }).status === 0 ? "git initialised" : "git not initialised";
1273
+ const inRepo = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root2, encoding: "utf8" }).stdout?.trim() === "true";
1274
+ const gitNote = inRepo ? "existing git repository kept" : spawnSync2("git", ["init", "-q", "-b", "main"], { cwd: root2 }).status === 0 ? "git initialised" : "git not initialised";
662
1275
  const where = relative2(opts.cwd, root2) || ".";
663
1276
  console.log(`
664
1277
  Set up project "${opts.name}" (${slug}) in ${root2} (core v${GTM_VERSION}; ${gitNote}).`);
@@ -672,30 +1285,52 @@ Next:
672
1285
  }
673
1286
 
674
1287
  // src/commands/job.ts
675
- var JOBS = ["tam", "enrich", "suppression:import", "signals", "qualify", "qa", "push", "activate", "results", "status"];
676
- function runJob(name) {
677
- console.error(
678
- `gtm job ${name ?? "<name>"}: not available yet (planned for Phase 2). Known jobs: ${JOBS.join(", ")}.
679
- Per AGENTS.md this is a mandatory stop: do not improvise the job with ad-hoc scripts.`
680
- );
681
- return false;
1288
+ import { createHash as createHash4 } from "crypto";
1289
+ import { spawnSync as spawnSync3 } from "child_process";
1290
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
1291
+ import { join as join12 } from "path";
1292
+ import { parse as parse6 } from "yaml";
1293
+
1294
+ // ../core/src/cache.ts
1295
+ import { createHash as createHash2 } from "crypto";
1296
+
1297
+ // ../core/src/stack-env.ts
1298
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
1299
+ import { homedir } from "os";
1300
+ import { join as join9 } from "path";
1301
+ 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;
1304
+ for (const line of readFileSync8(file, "utf8").split("\n")) {
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, "");
1307
+ }
1308
+ return true;
682
1309
  }
683
1310
 
1311
+ // ../core/src/adapters/mock-account-source.ts
1312
+ import { createHash as createHash3 } from "crypto";
1313
+
1314
+ // src/commands/validate.ts
1315
+ import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync10 } from "fs";
1316
+ import { join as join11 } from "path";
1317
+ import { parse as parse5 } from "yaml";
1318
+
684
1319
  // src/commands/migrate.ts
685
- import { existsSync as existsSync5, readFileSync as readFileSync6, renameSync, writeFileSync as writeFileSync4 } from "fs";
686
- import { join as join7, relative as relative3 } from "path";
1320
+ import { existsSync as existsSync8, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync4 } from "fs";
1321
+ import { join as join10, relative as relative3 } from "path";
687
1322
  import { parseDocument as parseDocument2 } from "yaml";
688
1323
  function projectYamlFiles(root2) {
689
1324
  const files = [
690
- { kind: "project", file: join7(root2, "project.yaml") },
691
- { kind: "icp", file: join7(root2, "context", "icp.yaml") },
692
- { kind: "signals", file: join7(root2, "context", "signals.yaml") },
693
- ...subdirs(join7(root2, "campaigns")).map((c) => ({ kind: "campaign-config", file: join7(root2, "campaigns", c, "config.yaml") }))
1325
+ { kind: "project", file: join10(root2, "project.yaml") },
1326
+ { kind: "icp", file: join10(root2, "context", "icp.yaml") },
1327
+ { kind: "signals", file: join10(root2, "context", "signals.yaml") },
1328
+ ...subdirs(join10(root2, "campaigns")).map((c) => ({ kind: "campaign-config", file: join10(root2, "campaigns", c, "config.yaml") }))
694
1329
  ];
695
- return files.filter((f) => existsSync5(f.file));
1330
+ return files.filter((f) => existsSync8(f.file));
696
1331
  }
697
1332
  function pendingRenames(root2) {
698
- return FILE_RENAMES.filter((r) => existsSync5(join7(root2, r.from)) && !existsSync5(join7(root2, r.to)));
1333
+ return FILE_RENAMES.filter((r) => existsSync8(join10(root2, r.from)) && !existsSync8(join10(root2, r.to)));
699
1334
  }
700
1335
  function runMigrate(root2, opts = {}) {
701
1336
  let ok = true;
@@ -704,10 +1339,10 @@ function runMigrate(root2, opts = {}) {
704
1339
  changed++;
705
1340
  console.log(` ${opts.dryRun ? "would rename" : "renamed"} ${from} \u2192 ${to}`);
706
1341
  if (opts.dryRun) continue;
707
- renameSync(join7(root2, from), join7(root2, to));
1342
+ renameSync(join10(root2, from), join10(root2, to));
708
1343
  if (to.endsWith(".md")) {
709
- const p = join7(root2, to);
710
- writeFileSync4(p, readFileSync6(p, "utf8").replace(new RegExp(`^# ${from.replace(".", "\\.")}`, "m"), `# ${to}`));
1344
+ const p = join10(root2, to);
1345
+ writeFileSync4(p, readFileSync9(p, "utf8").replace(new RegExp(`^# ${from.replace(".", "\\.")}`, "m"), `# ${to}`));
711
1346
  }
712
1347
  }
713
1348
  if (opts.dryRun && changed) {
@@ -716,7 +1351,7 @@ function runMigrate(root2, opts = {}) {
716
1351
  }
717
1352
  for (const { kind, file } of projectYamlFiles(root2)) {
718
1353
  const rel = relative3(root2, file);
719
- const doc = parseDocument2(readFileSync6(file, "utf8"));
1354
+ const doc = parseDocument2(readFileSync9(file, "utf8"));
720
1355
  if (doc.errors.length) {
721
1356
  console.log(` \u2717 ${rel}: invalid YAML: ${doc.errors[0].message}`);
722
1357
  ok = false;
@@ -743,96 +1378,7 @@ function runMigrate(root2, opts = {}) {
743
1378
  return ok;
744
1379
  }
745
1380
 
746
- // src/commands/new-campaign.ts
747
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
748
- import { join as join8 } from "path";
749
- import { parse } from "yaml";
750
- function runNewCampaign(root2, campaign) {
751
- if (!campaign || !Slug.safeParse(campaign).success) {
752
- console.error("Usage: gtm new-campaign <campaign-slug>");
753
- return false;
754
- }
755
- const projectFile = join8(root2, "project.yaml");
756
- if (!existsSync6(projectFile)) {
757
- console.error("project.yaml not found: run this from the root of a project repo");
758
- return false;
759
- }
760
- const dest = join8(root2, "campaigns", campaign);
761
- if (existsSync6(dest)) {
762
- console.error(`campaigns/${campaign} already exists`);
763
- return false;
764
- }
765
- const projectId = parse(readFileSync7(projectFile, "utf8"))?.project_id ?? "TODO";
766
- copyTemplate(join8(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
767
- mkdirSync3(join8(dest, "runs"), { recursive: true });
768
- writeFileSync5(join8(dest, "runs", ".gitkeep"), "");
769
- console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
770
- return true;
771
- }
772
-
773
- // src/commands/status.ts
774
- import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync8 } from "fs";
775
- import { join as join9 } from "path";
776
- import { parse as parse2 } from "yaml";
777
- function readYaml(file) {
778
- try {
779
- return parse2(readFileSync8(file, "utf8"));
780
- } catch {
781
- return null;
782
- }
783
- }
784
- function campaignStages(root2) {
785
- return subdirs(join9(root2, "campaigns")).map((id) => ({ id, stage: readYaml(join9(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
786
- }
787
- function runStatus(root2) {
788
- const project = readYaml(join9(root2, "project.yaml"));
789
- const icp = readYaml(join9(root2, "context", "icp.yaml"));
790
- console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
791
- console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
792
- `);
793
- const rows = subdirs(join9(root2, "campaigns")).map((id) => {
794
- const cfg = readYaml(join9(root2, "campaigns", id, "config.yaml")) ?? {};
795
- const runsDir = join9(root2, "campaigns", id, "runs");
796
- const last = existsSync7(runsDir) ? readdirSync2(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
797
- return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
798
- });
799
- if (!rows.length) {
800
- console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
801
- return true;
802
- }
803
- const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
804
- const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
805
- const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
806
- console.log(line(header));
807
- console.log(widths.map((w) => "-".repeat(w)).join(" "));
808
- for (const r of rows) console.log(line(r));
809
- return true;
810
- }
811
-
812
- // src/commands/upgrade.ts
813
- import { spawnSync as spawnSync2 } from "child_process";
814
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
815
- import { join as join11 } from "path";
816
-
817
- // src/changelog.ts
818
- function compareVersions(a, b) {
819
- const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
820
- const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
821
- for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
822
- return 0;
823
- }
824
- function changelogBetween(text, from, to) {
825
- const parts = text.split(/^(?=## )/m);
826
- return parts.filter((p) => {
827
- const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
828
- return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
829
- }).join("").trim();
830
- }
831
-
832
1381
  // src/commands/validate.ts
833
- import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync9 } from "fs";
834
- import { join as join10 } from "path";
835
- import { parse as parse3 } from "yaml";
836
1382
  var REQUIRED_CONTEXT = ["intake.md", "icp.md", "icp.yaml", "signals.yaml", "personas.md", "offer.md", "proof.md", "voice.md"];
837
1383
  var REQUIRED_CAMPAIGN = ["brief.md", "config.yaml", "copy.md"];
838
1384
  var DATA_FILES = /\.(csv|tsv|xlsx?|parquet|jsonl|ndjson)$/i;
@@ -842,21 +1388,21 @@ var ALLOWED_EMAIL = /@(example\.(com|org|net)|[a-z0-9-]+\.example)$/i;
842
1388
  var SKIP_DIRS = ["node_modules", ".git", ".gtm"];
843
1389
  var stageIndex = (s) => Stage.options.indexOf(s);
844
1390
  function readPackageJson(root2) {
845
- const p = join10(root2, "package.json");
846
- return existsSync8(p) ? JSON.parse(readFileSync9(p, "utf8")) : null;
1391
+ const p = join11(root2, "package.json");
1392
+ return existsSync9(p) ? JSON.parse(readFileSync10(p, "utf8")) : null;
847
1393
  }
848
1394
  function pinnedVersion(root2) {
849
1395
  const pkg = readPackageJson(root2);
850
1396
  return pkg?.devDependencies?.[PACKAGE_NAME] ?? pkg?.dependencies?.[PACKAGE_NAME] ?? null;
851
1397
  }
852
1398
  function loadVersioned(r, file, kind, schema) {
853
- if (!existsSync8(file)) {
1399
+ if (!existsSync9(file)) {
854
1400
  r.err(file, "missing");
855
1401
  return null;
856
1402
  }
857
1403
  let raw;
858
1404
  try {
859
- raw = parse3(readFileSync9(file, "utf8"));
1405
+ raw = parse5(readFileSync10(file, "utf8"));
860
1406
  } catch (e) {
861
1407
  r.err(file, `invalid YAML: ${e.message}`);
862
1408
  return null;
@@ -878,22 +1424,22 @@ function loadVersioned(r, file, kind, schema) {
878
1424
  function checkCoreSync(r, root2) {
879
1425
  const pinned = pinnedVersion(root2);
880
1426
  if (!pinned) return;
881
- if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(join10(root2, "package.json"), `${PACKAGE_NAME} must be pinned exactly (got "${pinned}")`);
882
- const versionFile = join10(root2, ".gtm", "VERSION");
883
- const synced = existsSync8(versionFile) ? readFileSync9(versionFile, "utf8").trim() : null;
1427
+ if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(join11(root2, "package.json"), `${PACKAGE_NAME} must be pinned exactly (got "${pinned}")`);
1428
+ const versionFile = join11(root2, ".gtm", "VERSION");
1429
+ const synced = existsSync9(versionFile) ? readFileSync10(versionFile, "utf8").trim() : null;
884
1430
  if (synced !== GTM_VERSION) r.err(versionFile, `is ${synced ?? "missing"}, installed core is ${GTM_VERSION}: run \`npx gtm sync\``);
885
- if (!existsSync8(join10(root2, "AGENTS.md"))) r.err(join10(root2, "AGENTS.md"), "missing: run `npx gtm sync`");
1431
+ if (!existsSync9(join11(root2, "AGENTS.md"))) r.err(join11(root2, "AGENTS.md"), "missing: run `npx gtm sync`");
886
1432
  }
887
1433
  function checkNoLeadData(r, root2) {
888
1434
  for (const rel of walk(root2, SKIP_DIRS)) {
889
- const file = join10(root2, rel);
1435
+ const file = join11(root2, rel);
890
1436
  if (DATA_FILES.test(rel)) {
891
1437
  r.err(file, "data files are not allowed in a project repo (lead data belongs in Postgres)");
892
1438
  continue;
893
1439
  }
894
1440
  if (!/\.(md|ya?ml|txt)$/i.test(rel) || rel === "pnpm-lock.yaml") continue;
895
- if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(readFileSync9(file, "utf8"))) r.warn(file, "still contains TODO placeholders");
896
- const found = (readFileSync9(file, "utf8").match(EMAIL) ?? []).filter((e) => !ALLOWED_EMAIL.test(e));
1441
+ if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(readFileSync10(file, "utf8"))) r.warn(file, "still contains TODO placeholders");
1442
+ const found = (readFileSync10(file, "utf8").match(EMAIL) ?? []).filter((e) => !ALLOWED_EMAIL.test(e));
897
1443
  if (found.length) r.warn(file, `contains email address(es) ${[...new Set(found)].slice(0, 3).join(", ")}: confirm none are prospects`);
898
1444
  }
899
1445
  }
@@ -908,11 +1454,24 @@ function checkPinnedApproval(r, contentFile, approval, current, what, approveCmd
908
1454
  r.err(contentFile, `${what} changed after approval (approved ${pinned}, now ${current}). Re-present it to the approver and record it with \`${approveCmd} --by "<name>"\`, or revert the change`);
909
1455
  return false;
910
1456
  }
911
- function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApproved) {
912
- const dir = join10(root2, "campaigns", campaign);
1457
+ function checkGitHubMode(r, file, what, approval, approver, prApprovals, content) {
1458
+ if (approval.status !== "approved" || !isGitHubHandle(approver)) return true;
1459
+ if (!approval.approved_via) {
1460
+ r.err(file, `${what} approval was recorded without a pull request, but the approver is ${approver} (GitHub mode): get an APPROVED review on a PR and run \`npx gtm approve ... --pr <number>\``);
1461
+ return false;
1462
+ }
1463
+ if (!approval.approved_by || login(approval.approved_by) !== login(approver)) {
1464
+ r.err(file, `${what} was approved by ${approval.approved_by}, but the project approver is ${approver}`);
1465
+ return false;
1466
+ }
1467
+ prApprovals.push({ file, what, approval, approver, ...content });
1468
+ return true;
1469
+ }
1470
+ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApproved, approver, prApprovals) {
1471
+ const dir = join11(root2, "campaigns", campaign);
913
1472
  console.log(`campaign ${campaign}`);
914
- for (const f of REQUIRED_CAMPAIGN) if (!existsSync8(join10(dir, f))) r.err(join10(dir, f), "missing");
915
- const cfgFile = join10(dir, "config.yaml");
1473
+ for (const f of REQUIRED_CAMPAIGN) if (!existsSync9(join11(dir, f))) r.err(join11(dir, f), "missing");
1474
+ const cfgFile = join11(dir, "config.yaml");
916
1475
  const cfg = loadVersioned(r, cfgFile, "campaign-config", CampaignConfig);
917
1476
  if (cfg) {
918
1477
  if (cfg.project_id !== projectId) r.err(cfgFile, `project_id "${cfg.project_id}" does not match project.yaml "${projectId}"`);
@@ -929,16 +1488,16 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
929
1488
  }
930
1489
  const providers = /* @__PURE__ */ new Set([...cfg.enrichment.email_waterfall, cfg.enrichment.verification.provider, cfg.sending.sender]);
931
1490
  for (const p of providers) {
932
- if (!p.startsWith("mock-") && !existsSync8(join10(KIT_DIR, "providers", `${p}.md`))) r.warn(cfgFile, `no .gtm/providers/${p}.md documenting "${p}"`);
1491
+ if (!p.startsWith("mock-") && !existsSync9(join11(KIT_DIR, "providers", `${p}.md`))) r.warn(cfgFile, `no .gtm/providers/${p}.md documenting "${p}"`);
933
1492
  }
934
- if (stageIndex(cfg.stage) > stageIndex("tam") && !existsSync8(join10(root2, "tam", `${cfg.tam_id}.md`))) {
1493
+ if (stageIndex(cfg.stage) > stageIndex("tam") && !existsSync9(join11(root2, "tam", `${cfg.tam_id}.md`))) {
935
1494
  r.err(cfgFile, `stage is "${cfg.stage}" but tam/${cfg.tam_id}.md does not exist`);
936
1495
  }
937
1496
  if (stageIndex(cfg.stage) > stageIndex("icp") && !icpApproved) {
938
1497
  r.err(cfgFile, `stage is "${cfg.stage}" but the ICP gate (context/icp.yaml approval) is not approved, or the ICP changed since approval`);
939
1498
  }
940
- const copyFile = join10(dir, "copy.md");
941
- const copyApproved = existsSync8(copyFile) ? checkPinnedApproval(r, copyFile, cfg.gates.copy, copyContentHash(readFileSync9(copyFile, "utf8")), "copy", `npx gtm approve copy ${campaign}`) : false;
1499
+ const copyFile = join11(dir, "copy.md");
1500
+ const copyApproved = existsSync9(copyFile) ? checkPinnedApproval(r, copyFile, cfg.gates.copy, copyContentHash(readFileSync10(copyFile, "utf8")), "copy", `npx gtm approve copy ${campaign}`) && checkGitHubMode(r, cfgFile, `copy (${campaign})`, cfg.gates.copy, approver, prApprovals, { contentPath: `campaigns/${campaign}/copy.md`, hashOf: copyContentHash }) : false;
942
1501
  if (stageIndex(cfg.stage) > stageIndex("copy") && !copyApproved) {
943
1502
  r.err(cfgFile, `stage is "${cfg.stage}" but gates.copy is not approved, or copy.md changed since approval`);
944
1503
  }
@@ -948,64 +1507,266 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
948
1507
  if (cfg.gates.launch.status === "approved" && !copyApproved) {
949
1508
  r.err(cfgFile, "gates.launch is approved but gates.copy is not");
950
1509
  }
951
- if (stageIndex(cfg.stage) > stageIndex("qa") && !existsSync8(join10(dir, "qa.md"))) {
952
- r.err(join10(dir, "qa.md"), "missing: QA report is required before launch");
1510
+ if (stageIndex(cfg.stage) > stageIndex("qa") && !existsSync9(join11(dir, "qa.md"))) {
1511
+ r.err(join11(dir, "qa.md"), "missing: QA report is required before launch");
953
1512
  }
954
1513
  }
955
- const runs = join10(dir, "runs");
956
- if (existsSync8(runs)) {
957
- for (const f of readdirSync3(runs).filter((f2) => !f2.startsWith("."))) {
958
- if (!RUN_LOG_NAME.test(f)) r.err(join10(runs, f), "run logs must be named YYYY-MM-DD-<user>[-n].md");
1514
+ const runs = join11(dir, "runs");
1515
+ if (existsSync9(runs)) {
1516
+ for (const f of readdirSync2(runs).filter((f2) => !f2.startsWith("."))) {
1517
+ if (!RUN_LOG_NAME.test(f)) r.err(join11(runs, f), "run logs must be named YYYY-MM-DD-<user>[-n].md");
959
1518
  }
960
1519
  }
961
1520
  }
962
- function runValidate(root2, onlyCampaign) {
1521
+ async function runValidate(root2, onlyCampaign, opts = {}) {
963
1522
  const r = new Report(root2);
964
1523
  console.log(`validating ${root2} against core v${GTM_VERSION}`);
965
1524
  checkCoreSync(r, root2);
966
- for (const { from, to } of pendingRenames(root2)) r.err(join10(root2, from), `renamed to ${to} in core 0.3.0: run \`npx gtm migrate\``);
967
- const meta = loadVersioned(r, join10(root2, "project.yaml"), "project", ProjectFile);
1525
+ for (const { from, to } of pendingRenames(root2)) r.err(join11(root2, from), `renamed to ${to} in core 0.3.0: run \`npx gtm migrate\``);
1526
+ const meta = loadVersioned(r, join11(root2, "project.yaml"), "project", ProjectFile);
968
1527
  const projectId = meta?.project_id ?? "";
969
1528
  if (meta) {
970
1529
  for (const key of Object.keys(meta.features)) {
971
- if (!FEATURES[key]) r.err(join10(root2, "project.yaml"), `unknown feature "${key}" (known: ${Object.keys(FEATURES).join(", ") || "none"})`);
1530
+ if (!FEATURES[key]) r.err(join11(root2, "project.yaml"), `unknown feature "${key}" (known: ${Object.keys(FEATURES).join(", ") || "none"})`);
972
1531
  }
973
1532
  }
974
- if (!existsSync8(join10(root2, "PROJECT.md"))) r.warn(join10(root2, "PROJECT.md"), "missing (project-specific rules for agents)");
975
- for (const f of REQUIRED_CONTEXT) if (!existsSync8(join10(root2, "context", f))) r.err(join10(root2, "context", f), "missing");
976
- const icpFile = join10(root2, "context", "icp.yaml");
977
- const sigFile = join10(root2, "context", "signals.yaml");
978
- const icp = existsSync8(icpFile) ? loadVersioned(r, icpFile, "icp", IcpFile) : null;
979
- const signals = existsSync8(sigFile) ? loadVersioned(r, sigFile, "signals", SignalsFile) : null;
1533
+ if (!existsSync9(join11(root2, "context", "research.md"))) r.warn(join11(root2, "context", "research.md"), "missing: run the research skill (.gtm/skills/research/SKILL.md) before intake questions");
1534
+ if (!existsSync9(join11(root2, "PROJECT.md"))) r.warn(join11(root2, "PROJECT.md"), "missing (project-specific rules for agents)");
1535
+ for (const f of REQUIRED_CONTEXT) if (!existsSync9(join11(root2, "context", f))) r.err(join11(root2, "context", f), "missing");
1536
+ const icpFile = join11(root2, "context", "icp.yaml");
1537
+ const sigFile = join11(root2, "context", "signals.yaml");
1538
+ const icp = existsSync9(icpFile) ? loadVersioned(r, icpFile, "icp", IcpFile) : null;
1539
+ const signals = existsSync9(sigFile) ? loadVersioned(r, sigFile, "signals", SignalsFile) : null;
980
1540
  if (meta && icp && icp.project_id !== projectId) r.err(icpFile, `project_id "${icp.project_id}" does not match project.yaml "${projectId}"`);
981
1541
  if (meta && signals && signals.project_id !== projectId) r.err(sigFile, `project_id "${signals.project_id}" does not match project.yaml "${projectId}"`);
982
- const icpApproved = icp ? checkPinnedApproval(r, icpFile, icp.approval, icpContentHash(parse3(readFileSync9(icpFile, "utf8"))), "ICP", "npx gtm approve icp") : false;
1542
+ const approver = meta?.owners.approver;
1543
+ const prApprovals = [];
1544
+ const icpApproved = icp ? checkPinnedApproval(r, icpFile, icp.approval, icpContentHash(parse5(readFileSync10(icpFile, "utf8"))), "ICP", "npx gtm approve icp") && checkGitHubMode(r, icpFile, "ICP", icp.approval, approver, prApprovals, { contentPath: "context/icp.yaml", hashOf: (t) => icpContentHash(parse5(t)) }) : false;
983
1545
  checkNoLeadData(r, root2);
984
- const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(join10(root2, "campaigns"));
1546
+ const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(join11(root2, "campaigns"));
985
1547
  for (const c of campaigns) {
986
- if (!existsSync8(join10(root2, "campaigns", c))) {
987
- r.err(join10(root2, "campaigns", c), "campaign folder not found");
1548
+ if (!existsSync9(join11(root2, "campaigns", c))) {
1549
+ r.err(join11(root2, "campaigns", c), "campaign folder not found");
988
1550
  continue;
989
1551
  }
990
- validateCampaign(r, root2, projectId, c, icp, signals, icpApproved);
1552
+ validateCampaign(r, root2, projectId, c, icp, signals, icpApproved, approver, prApprovals);
1553
+ }
1554
+ if (opts.verifyGithub && prApprovals.length) {
1555
+ const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
1556
+ if (typeof api === "string") r.err(root2, `--verify-github: ${api}`);
1557
+ else {
1558
+ for (const p of prApprovals) {
1559
+ const ok = await verifyRecordedApproval(api, p.approval.approved_via, p.approver, p.contentPath, p.approval.content_hash, p.hashOf);
1560
+ if (ok === true) console.log(` \u2713 ${p.what}: ${p.approver} approved ${p.approval.approved_via}`);
1561
+ else r.err(p.file, `${p.what}: ${ok}`);
1562
+ }
1563
+ }
991
1564
  }
992
1565
  r.summary();
993
1566
  return r.errors === 0;
994
1567
  }
995
1568
 
1569
+ // src/commands/job.ts
1570
+ var PLANNED = ["enrich", "suppression:import", "signals", "qualify", "qa", "push", "activate", "results"];
1571
+ function git(root2, args2) {
1572
+ const res = spawnSync3("git", args2, { cwd: root2, encoding: "utf8" });
1573
+ return res.status === 0 ? res.stdout.trim() : null;
1574
+ }
1575
+ function buildTamInput(root2, campaign) {
1576
+ const read = (rel) => parse6(readFileSync11(join12(root2, rel), "utf8"));
1577
+ const project = ProjectFile.parse(read("project.yaml"));
1578
+ const icp = IcpFile.parse(read("context/icp.yaml"));
1579
+ const cfgText = readFileSync11(join12(root2, "campaigns", campaign, "config.yaml"), "utf8");
1580
+ const cfg = CampaignConfig.parse(parse6(cfgText));
1581
+ if (icp.approval.status !== "approved") return "TAM needs an approved ICP (hard stop, AGENTS.md section 4). Get ICP sign-off first.";
1582
+ if (Stage.options.indexOf(cfg.stage) < Stage.options.indexOf("tam")) return `campaign stage is "${cfg.stage}": move it to "tam" first.`;
1583
+ if (!cfg.tam) return "config.yaml has no `tam:` section. Add `tam: { source: mock-accounts }` for a local proof, or a real account source.";
1584
+ const segments = resolveIcp(icp).segments.filter((s) => cfg.segments.includes(s.id)).map(({ id, description, firmographics }) => ({ id, description, firmographics }));
1585
+ return TamJobInput.parse({
1586
+ ctx: {
1587
+ project_id: project.project_id,
1588
+ campaign_id: campaign,
1589
+ triggered_by: process.env.GTM_USER ?? git(root2, ["config", "user.name"]) ?? "unknown",
1590
+ core_version: GTM_VERSION,
1591
+ git_sha: git(root2, ["rev-parse", "HEAD"]),
1592
+ config_hash: `sha256:${createHash4("sha256").update(cfgText).digest("hex").slice(0, 16)}`
1593
+ },
1594
+ tam_id: cfg.tam_id,
1595
+ source: cfg.tam.source,
1596
+ max_accounts_per_segment: cfg.tam.max_accounts_per_segment,
1597
+ segments,
1598
+ exclusions: icp.exclusions,
1599
+ budget_usd: cfg.budget.max_total
1600
+ });
1601
+ }
1602
+ function printTam(res, campaign) {
1603
+ const header = ["segment", "found", "excluded", "new", "updated", "reachable", "active"];
1604
+ const rows = res.segments.map((s) => [s.segment_id, s.found, s.excluded, s.inserted, s.updated, s.reachable, s.active].map(String));
1605
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1606
+ const line = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i])).join(" ");
1607
+ console.log(`
1608
+ ${line(header)}
1609
+ ${widths.map((w) => "-".repeat(w)).join(" ")}`);
1610
+ for (const r of rows) console.log(line(r));
1611
+ console.log(`
1612
+ Funnel: TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} cost $${res.cost_usd.toFixed(2)} run ${res.run_id}`);
1613
+ console.log(`
1614
+ Run log row (campaigns/${campaign}/runs/\u2026):
1615
+ | tam | ${res.run_id} | succeeded | ${res.segments.length} segment(s) | TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} | ${res.cost_usd.toFixed(2)} |`);
1616
+ }
1617
+ async function runJob(root2, args2) {
1618
+ const [name, campaign] = args2;
1619
+ if (name && PLANNED.includes(name)) {
1620
+ console.error(`gtm job ${name}: not available yet (planned). Per AGENTS.md this is a mandatory stop: do not improvise it with scripts.`);
1621
+ return false;
1622
+ }
1623
+ if (name !== "tam" || !campaign) {
1624
+ console.error(`Usage: gtm job tam <campaign> (planned: ${PLANNED.join(", ")})`);
1625
+ return false;
1626
+ }
1627
+ if (!existsSync10(join12(root2, "campaigns", campaign, "config.yaml"))) {
1628
+ console.error(`campaigns/${campaign}/config.yaml not found`);
1629
+ return false;
1630
+ }
1631
+ console.log(`\u2022 validating ${campaign}`);
1632
+ if (!await runValidate(root2, campaign)) {
1633
+ console.error("\nFix the validation errors above before running jobs.");
1634
+ return false;
1635
+ }
1636
+ let input;
1637
+ try {
1638
+ input = buildTamInput(root2, campaign);
1639
+ } catch (e) {
1640
+ console.error(e.message);
1641
+ return false;
1642
+ }
1643
+ if (typeof input === "string") {
1644
+ console.error(input);
1645
+ return false;
1646
+ }
1647
+ loadStackEnv();
1648
+ 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.`);
1650
+ return false;
1651
+ }
1652
+ const { HatchetClient } = await import("@hatchet-dev/typescript-sdk/v1");
1653
+ const hatchet = HatchetClient.init();
1654
+ const tam = hatchet.task({ name: JOB_NAMES.tam, fn: async () => {
1655
+ throw new Error("runs on the gtm worker");
1656
+ } });
1657
+ 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);
1659
+ try {
1660
+ const output = await tam.run(input);
1661
+ printTam(TamJobResult.parse(output), campaign);
1662
+ return true;
1663
+ } catch (e) {
1664
+ console.error(`tam failed: ${e.message}`);
1665
+ return false;
1666
+ } finally {
1667
+ clearTimeout(hint);
1668
+ }
1669
+ }
1670
+
1671
+ // src/commands/new-campaign.ts
1672
+ import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
1673
+ import { join as join13 } from "path";
1674
+ import { parse as parse7 } from "yaml";
1675
+ function runNewCampaign(root2, campaign) {
1676
+ if (!campaign || !Slug.safeParse(campaign).success) {
1677
+ console.error("Usage: gtm new-campaign <campaign-slug>");
1678
+ return false;
1679
+ }
1680
+ const projectFile = join13(root2, "project.yaml");
1681
+ if (!existsSync11(projectFile)) {
1682
+ console.error("project.yaml not found: run this from the root of a project repo");
1683
+ return false;
1684
+ }
1685
+ const dest = join13(root2, "campaigns", campaign);
1686
+ if (existsSync11(dest)) {
1687
+ console.error(`campaigns/${campaign} already exists`);
1688
+ return false;
1689
+ }
1690
+ const projectId = parse7(readFileSync12(projectFile, "utf8"))?.project_id ?? "TODO";
1691
+ copyTemplate(join13(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
1692
+ mkdirSync3(join13(dest, "runs"), { recursive: true });
1693
+ writeFileSync5(join13(dest, "runs", ".gitkeep"), "");
1694
+ console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
1695
+ return true;
1696
+ }
1697
+
1698
+ // src/commands/status.ts
1699
+ import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13 } from "fs";
1700
+ import { join as join14 } from "path";
1701
+ import { parse as parse8 } from "yaml";
1702
+ function readYaml2(file) {
1703
+ try {
1704
+ return parse8(readFileSync13(file, "utf8"));
1705
+ } catch {
1706
+ return null;
1707
+ }
1708
+ }
1709
+ function campaignStages(root2) {
1710
+ return subdirs(join14(root2, "campaigns")).map((id) => ({ id, stage: readYaml2(join14(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
1711
+ }
1712
+ function runStatus(root2) {
1713
+ const project = readYaml2(join14(root2, "project.yaml"));
1714
+ const icp = readYaml2(join14(root2, "context", "icp.yaml"));
1715
+ console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
1716
+ console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
1717
+ `);
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) ?? "-" : "-";
1722
+ return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
1723
+ });
1724
+ if (!rows.length) {
1725
+ console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
1726
+ return true;
1727
+ }
1728
+ const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
1729
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
1730
+ const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
1731
+ console.log(line(header));
1732
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
1733
+ for (const r of rows) console.log(line(r));
1734
+ return true;
1735
+ }
1736
+
1737
+ // 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";
1741
+
1742
+ // src/changelog.ts
1743
+ function compareVersions(a, b) {
1744
+ const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
1745
+ const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
1746
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
1747
+ return 0;
1748
+ }
1749
+ function changelogBetween(text, from, to) {
1750
+ const parts = text.split(/^(?=## )/m);
1751
+ return parts.filter((p) => {
1752
+ const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
1753
+ return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
1754
+ }).join("").trim();
1755
+ }
1756
+
996
1757
  // src/commands/upgrade.ts
997
1758
  function detectPackageManager(root2) {
998
- if (existsSync9(join11(root2, "pnpm-lock.yaml"))) return "pnpm";
999
- if (existsSync9(join11(root2, "yarn.lock"))) return "yarn";
1759
+ if (existsSync13(join15(root2, "pnpm-lock.yaml"))) return "pnpm";
1760
+ if (existsSync13(join15(root2, "yarn.lock"))) return "yarn";
1000
1761
  return "npm";
1001
1762
  }
1002
1763
  function installExact(root2, pm, spec) {
1003
- const onPath = (bin) => spawnSync2(bin, ["--version"], { stdio: "ignore" }).status === 0;
1764
+ const onPath = (bin) => spawnSync4(bin, ["--version"], { stdio: "ignore" }).status === 0;
1004
1765
  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]];
1005
- return spawnSync2(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
1766
+ return spawnSync4(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
1006
1767
  }
1007
1768
  function gtm(root2, args2) {
1008
- return spawnSync2("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
1769
+ return spawnSync4("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
1009
1770
  }
1010
1771
  var FROZEN_STAGES = ["qa", "launch"];
1011
1772
  function runUpgrade(root2, opts) {
@@ -1020,7 +1781,7 @@ function runUpgrade(root2, opts) {
1020
1781
  console.error("Finish the launch first, or re-run with --force if a GTM engineer approved it.");
1021
1782
  return false;
1022
1783
  }
1023
- const dirty = spawnSync2("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
1784
+ const dirty = spawnSync4("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
1024
1785
  if (dirty.status === 0 && dirty.stdout.trim() && !opts.force) {
1025
1786
  console.error("Refusing to upgrade with uncommitted changes. Commit or stash first (or --force).");
1026
1787
  return false;
@@ -1033,9 +1794,9 @@ function runUpgrade(root2, opts) {
1033
1794
  if (gtm(root2, ["migrate"]) !== 0) return false;
1034
1795
  const valid = gtm(root2, ["validate"]) === 0;
1035
1796
  const after = pinnedVersion(root2) ?? target;
1036
- const changelog = join11(root2, "node_modules", ...PACKAGE_NAME.split("/"), "CHANGELOG.md");
1037
- if (existsSync9(changelog)) {
1038
- const notes = changelogBetween(readFileSync10(changelog, "utf8"), before, after);
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);
1039
1800
  console.log(`
1040
1801
  === Changes ${before} \u2192 ${after} ===
1041
1802
  ${notes || "(no changelog entries)"}
@@ -1070,16 +1831,18 @@ var COMMANDS = {
1070
1831
  examples: ["git switch -c campaign/q1-uk-cfo && npx gtm new-campaign q1-uk-cfo"]
1071
1832
  },
1072
1833
  validate: {
1073
- usage: "gtm validate [campaign]",
1834
+ usage: "gtm validate [campaign] [--verify-github]",
1074
1835
  summary: "check repo against schemas, gates, data rules",
1075
1836
  details: [
1076
1837
  "Checks project.yaml, context/ and campaigns against the schemas; segment/persona/signal references;",
1077
1838
  "gate order (no stage past an unapproved or changed ICP/copy approval); required files; run-log names;",
1078
1839
  "no lead data (blocks data files, warns on email addresses); .gtm/ matches the installed core.",
1840
+ "GitHub mode: ICP/copy approvals must come from the approver's PR review (approved_via).",
1841
+ "--verify-github (CI) re-checks each recorded PR approval on GitHub.",
1079
1842
  "Exit code 1 if there is any error; warnings never fail."
1080
1843
  ],
1081
1844
  writes: "Nothing (read-only).",
1082
- examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo"]
1845
+ examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo", "npx gtm validate --verify-github"]
1083
1846
  },
1084
1847
  status: {
1085
1848
  usage: "gtm status",
@@ -1088,26 +1851,64 @@ var COMMANDS = {
1088
1851
  writes: "Nothing (read-only).",
1089
1852
  examples: ["npx gtm status"]
1090
1853
  },
1854
+ icp: {
1855
+ usage: "gtm icp",
1856
+ summary: "show the ICP as jobs see it (core + segment overrides)",
1857
+ details: [
1858
+ "Prints the core profile, then every segment's FINAL filters (core with the segment's fields",
1859
+ "replacing the same core fields), which fields the segment overrides, its personas, and the",
1860
+ "shared exclusions. Use it to review an ICP before sign-off and to check what `gtm job tam` searches."
1861
+ ],
1862
+ writes: "Nothing (read-only).",
1863
+ examples: ["npx gtm icp"]
1864
+ },
1091
1865
  approve: {
1092
- usage: 'gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]',
1866
+ usage: 'gtm approve icp --pr <number> [--notes ".."]\ngtm approve copy <campaign> --pr <number> [--notes ".."]\ngtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [..]',
1093
1867
  summary: "record a sign-off and pin a hash of the approved content",
1094
1868
  details: [
1095
- "Run ONLY after the named human explicitly approved in this session (AGENTS.md section 4).",
1096
- "Pins a content hash: if context/icp.yaml or the campaign's copy.md changes afterwards,",
1097
- "`gtm validate` fails until it is re-approved. Refuses if the file would not validate.",
1098
- "The launch gate is recorded by hand in config.yaml (its content is the lead set)."
1869
+ "GitHub mode (project.yaml owners.approver is an @handle): the approval IS the approver's APPROVED",
1870
+ "review on a pull request. --pr verifies on GitHub that the PR changes the file, the approver approved",
1871
+ "its latest commit, and your local file equals the approved version; then records approved_via.",
1872
+ "Recorded mode (approver is a name): run ONLY after the named human explicitly approved in this session.",
1873
+ "Either way a content hash is pinned: if the ICP or copy changes later, `gtm validate` fails.",
1874
+ "The launch gate is recorded by hand in config.yaml."
1099
1875
  ],
1100
1876
  writes: "context/icp.yaml \u2192 approval (icp), or campaigns/<campaign>/config.yaml \u2192 gates.copy (copy).",
1101
- examples: ['npx gtm approve icp --by "Maria Chen (VP Marketing)" --notes "Segments A+B, personas CFO+AP"', 'npx gtm approve copy q1-uk-cfo --by "Maria Chen (VP Marketing)"']
1877
+ examples: ["npx gtm approve icp --pr 12", "npx gtm approve copy q1-uk-cfo --pr 15", 'npx gtm approve icp --by "Maria Chen (VP Marketing)"']
1878
+ },
1879
+ track: {
1880
+ usage: "gtm track stage <campaign>\ngtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]\ngtm track gate-done <icp|copy|launch> [campaign]",
1881
+ summary: "mirror stages and gates into the project tracker",
1882
+ details: [
1883
+ "Uses project.yaml \u2192 tracker.kind (github | none). GitHub: one issue per campaign (label stage:<stage>)",
1884
+ "and one per gate, assigned to the approver. Re-running updates the same issue.",
1885
+ "The tracker is a mirror only: stage and gates live in the YAML files. gate-done refuses unless the",
1886
+ "files say the gate is approved."
1887
+ ],
1888
+ writes: "Issues in the tracker (GitHub: issues, labels, comments). No local files.",
1889
+ examples: ["npx gtm track stage q1-uk-cfo", "npx gtm track gate-open icp --pr 12", "npx gtm track gate-done copy q1-uk-cfo"]
1890
+ },
1891
+ github: {
1892
+ usage: "gtm github setup [--apply]",
1893
+ summary: "prepare the GitHub repo: labels and approval ruleset",
1894
+ details: [
1895
+ "Creates the labels gtm track uses, and a ruleset on the default branch: PR required, review from",
1896
+ "CODEOWNERS required (the approver owns icp.yaml, signals.yaml, copy.md), CI job `check` must pass.",
1897
+ "Without --apply it only prints the plan. Needs admin rights; private repos need a paid GitHub plan",
1898
+ "for rulesets (CI verification via `validate --verify-github` works on any plan)."
1899
+ ],
1900
+ writes: "With --apply: labels and a ruleset on GitHub. Without: nothing.",
1901
+ examples: ["npx gtm github setup", "npx gtm github setup --apply"]
1102
1902
  },
1103
1903
  sync: {
1104
1904
  usage: "gtm sync [--check]",
1105
1905
  summary: "copy core kit into .gtm/ + AGENTS.md",
1106
1906
  details: [
1107
- "Makes .gtm/ (skills, templates, schemas, provider docs) and AGENTS.md match the installed core.",
1907
+ "Makes .gtm/ (skills, templates, schemas, guides, provider docs) and AGENTS.md match the installed core,",
1908
+ "and maintains the gtm block in .github/CODEOWNERS from project.yaml \u2192 owners.approver.",
1108
1909
  "--check only compares and exits 1 if they differ (CI uses this; never edit .gtm/ by hand)."
1109
1910
  ],
1110
- writes: ".gtm/ (replaced entirely) and AGENTS.md; creates CLAUDE.md if missing. With --check: nothing.",
1911
+ writes: ".gtm/ (replaced entirely), AGENTS.md, the gtm block in .github/CODEOWNERS; creates CLAUDE.md if missing. With --check: nothing.",
1111
1912
  examples: ["npx gtm sync", "npx gtm sync --check"]
1112
1913
  },
1113
1914
  migrate: {
@@ -1128,13 +1929,16 @@ var COMMANDS = {
1128
1929
  examples: ["npx gtm upgrade", "npx gtm upgrade 0.3.0"]
1129
1930
  },
1130
1931
  job: {
1131
- usage: "gtm job <name> <campaign>",
1132
- summary: "run a pipeline job (Phase 2)",
1932
+ usage: "gtm job tam <campaign>",
1933
+ summary: "run a pipeline job on the workers",
1133
1934
  details: [
1134
- "Planned: tam, enrich, suppression:import, signals, qualify, qa, push, activate, results, status.",
1135
- "Not available yet: it exits 1. Per AGENTS.md this is a stop; do not improvise jobs with ad-hoc scripts."
1935
+ "tam: builds the TAM for the campaign's segments into the database (needs an approved ICP and",
1936
+ "config.yaml \u2192 tam.source; mock-accounts for a local proof) and prints the funnel, cost and run_id.",
1937
+ "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).",
1939
+ "Planned (exit 1, a stop per AGENTS.md): enrich, suppression:import, signals, qualify, qa, push, activate, results."
1136
1940
  ],
1137
- writes: "Nothing yet.",
1941
+ writes: "Rows in Postgres (runs, accounts, costs). No repo files: copy the printed run-log row into the run log.",
1138
1942
  examples: ["npx gtm job tam q1-uk-cfo"]
1139
1943
  }
1140
1944
  };
@@ -1184,6 +1988,9 @@ var { values, positionals } = parseArgs({
1184
1988
  name: { type: "string" },
1185
1989
  dir: { type: "string" },
1186
1990
  slug: { type: "string" },
1991
+ pr: { type: "string" },
1992
+ apply: { type: "boolean" },
1993
+ "verify-github": { type: "boolean" },
1187
1994
  by: { type: "string" },
1188
1995
  notes: { type: "string" },
1189
1996
  date: { type: "string" },
@@ -1212,7 +2019,7 @@ ${generalHelp(GTM_VERSION)}`);
1212
2019
  console.log(text);
1213
2020
  return true;
1214
2021
  }
1215
- function run() {
2022
+ async function run() {
1216
2023
  if (values.version) {
1217
2024
  console.log(GTM_VERSION);
1218
2025
  return true;
@@ -1224,11 +2031,17 @@ function run() {
1224
2031
  case "new-campaign":
1225
2032
  return runNewCampaign(root, args[0]);
1226
2033
  case "validate":
1227
- return runValidate(root, args[0]);
2034
+ return runValidate(root, args[0], { verifyGithub: values["verify-github"] });
1228
2035
  case "status":
1229
2036
  return runStatus(root);
2037
+ case "icp":
2038
+ return runIcp(root);
1230
2039
  case "approve":
1231
- return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date });
2040
+ return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date, pr: values.pr });
2041
+ case "track":
2042
+ return runTrack(root, args, { pr: values.pr });
2043
+ case "github":
2044
+ return runGithub(root, args, { apply: values.apply });
1232
2045
  case "sync":
1233
2046
  return runSync(root, { check: values.check });
1234
2047
  case "migrate":
@@ -1236,7 +2049,7 @@ function run() {
1236
2049
  case "upgrade":
1237
2050
  return runUpgrade(root, { version: args[0], force: values.force });
1238
2051
  case "job":
1239
- return runJob(args[0]);
2052
+ return runJob(root, args);
1240
2053
  case void 0:
1241
2054
  return help(void 0);
1242
2055
  case "help":
@@ -1248,4 +2061,4 @@ ${generalHelp(GTM_VERSION)}`);
1248
2061
  return false;
1249
2062
  }
1250
2063
  }
1251
- process.exit(run() ? 0 : 1);
2064
+ process.exit(await run() ? 0 : 1);